合并本地领先源码:旧UI、GM疲劳燃烧、白金徽章孔、宠物修复、支援兵普攻开关
This commit is contained in:
1 parent
f2debaf9bf
commit
70b3c5a6dc
57 files changed
+4256
-30
No files matched your search
@@ -0,0 +1,544 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
||||
)
|
||||
|
||||
// Headless volunteer sessions.
|
||||
//
|
||||
// These are real game sessions from the server's point of view: they are in
|
||||
// Service.gameSessions, have a character generation, a native party peer
|
||||
// endpoint, and can be driven through the ordinary party/dungeon owners. Their
|
||||
// transport is a sink: outbound packets are accepted and discarded because no
|
||||
// client is attached. The owner's real client receives the normal party
|
||||
// prelude (op11/op9/op153 + per-member mode0/mode1) and therefore sees real
|
||||
// party actors, not the APC projection.
|
||||
//
|
||||
// This path is gated and OFF by default while the AIC action driver is not yet
|
||||
// attached.
|
||||
const (
|
||||
currentCompanionHeadlessEnv = "DNF_COMPANION_HEADLESS"
|
||||
currentCompanionHeadlessFlagFile = "companion_headless.enabled"
|
||||
currentCompanionHeadlessPortBase = 45000
|
||||
)
|
||||
|
||||
func currentCompanionHeadlessEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionHeadlessEnv))) {
|
||||
case "0", "off", "false", "no":
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(currentCompanionHeadlessFlagFile); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func headlessDebug(message string) {
|
||||
file, err := os.OpenFile("companion_headless_debug.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
_, _ = file.WriteString(time.Now().Format("15:04:05.000") + " " + message + "\n")
|
||||
}
|
||||
|
||||
type companionHeadlessConn struct {
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
port int
|
||||
}
|
||||
|
||||
func (c *companionHeadlessConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (c *companionHeadlessConn) Write(data []byte) (int, error) {
|
||||
return len(data), nil
|
||||
}
|
||||
func (c *companionHeadlessConn) Close() error {
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (c *companionHeadlessConn) LocalAddr() net.Addr {
|
||||
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: c.port}
|
||||
}
|
||||
func (c *companionHeadlessConn) RemoteAddr() net.Addr {
|
||||
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: c.port}
|
||||
}
|
||||
func (c *companionHeadlessConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *companionHeadlessConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *companionHeadlessConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func (s *Service) ensureHeadlessCompanionSessions(
|
||||
ctx context.Context,
|
||||
ownerSession *gameSession,
|
||||
sources []string,
|
||||
accountID string,
|
||||
) []uint16 {
|
||||
if s == nil || ownerSession == nil || len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
headlessDebug(fmt.Sprintf("ensure_sessions owner=%d sources=%v", ownerSession.selectedCharacterID, sources))
|
||||
created := make([]uint16, 0, len(sources))
|
||||
for _, raw := range sources {
|
||||
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
|
||||
if !ok || id == ownerSession.selectedCharacterID {
|
||||
continue
|
||||
}
|
||||
if real, online := s.onlineGameSession(id); online && real != nil {
|
||||
if !real.companionHeadless {
|
||||
continue
|
||||
}
|
||||
created = append(created, id)
|
||||
continue
|
||||
}
|
||||
session := &gameSession{
|
||||
conn: &companionHeadlessConn{port: currentCompanionHeadlessPortBase + int(id)},
|
||||
connID: "companion-headless-" + strconv.FormatUint(uint64(id), 10),
|
||||
accountID: accountID,
|
||||
selectedCharacterID: id,
|
||||
characterGeneration: s.nextCompanionGeneration(),
|
||||
channel: ownerSession.channel,
|
||||
companionHeadless: true,
|
||||
companionVirtual: false,
|
||||
companionOwnerID: ownerSession.selectedCharacterID,
|
||||
}
|
||||
session.partyPeerMu.Lock()
|
||||
session.partyPeer = currentPartyPeerEndpointRegistration{
|
||||
InnerIPv4: [4]byte{127, 0, 0, 1},
|
||||
OuterIPv4: [4]byte{127, 0, 0, 1},
|
||||
Port: uint16(currentCompanionHeadlessPortBase + int(id)),
|
||||
MTU: currentPartyPeerDefaultPathMTU,
|
||||
}
|
||||
session.partyRoutingKey = s.nextPartyRoutingKey.Add(1)
|
||||
session.partyPathMTU = currentPartyPeerDefaultPathMTU
|
||||
session.partyPeerMu.Unlock()
|
||||
session.townMu.Lock()
|
||||
session.townPositionSnapshot = ownerSession.townPositionSnapshot
|
||||
session.townMu.Unlock()
|
||||
s.mu.Lock()
|
||||
if s.gameSessions == nil {
|
||||
s.gameSessions = make(map[uint16]*gameSession)
|
||||
}
|
||||
s.gameSessions[id] = session
|
||||
s.mu.Unlock()
|
||||
go s.runHeadlessCompanionAIC(session)
|
||||
created = append(created, id)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
headlessDebug(fmt.Sprintf("sessions_created ids=%v", created))
|
||||
s.logGameEvent(ownerSession, "game-companion-headless-sessions-online",
|
||||
"owner_character_id", ownerSession.selectedCharacterID,
|
||||
"character_ids", uint16ListLog(created))
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
// dropHeadlessCompanionSessionsForOwner removes the previous login's headless
|
||||
// sessions when the feature is switched off. Without this, a relog after
|
||||
// deleting the flag would leave the synthetic party members in the manager and
|
||||
// the client would stay at "connecting".
|
||||
func (s *Service) dropHeadlessCompanionSessionsForOwner(ownerID uint16) {
|
||||
if s == nil || ownerID == 0 {
|
||||
return
|
||||
}
|
||||
manager := s.runtimePartyManagerForService()
|
||||
removed := make([]uint16, 0, 4)
|
||||
type removedMember struct {
|
||||
id uint16
|
||||
generation uint64
|
||||
}
|
||||
members := make([]removedMember, 0, 4)
|
||||
s.mu.Lock()
|
||||
for id, session := range s.gameSessions {
|
||||
if session == nil || !session.companionHeadless || session.companionOwnerID != ownerID {
|
||||
continue
|
||||
}
|
||||
delete(s.gameSessions, id)
|
||||
removed = append(removed, id)
|
||||
members = append(members, removedMember{id: id, generation: session.characterGeneration})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if manager != nil {
|
||||
for _, member := range members {
|
||||
manager.Leave(member.id, member.generation)
|
||||
}
|
||||
}
|
||||
if len(removed) != 0 {
|
||||
s.logPacketEvent("game-companion-headless-sessions-dropped",
|
||||
"owner_character_id", ownerID,
|
||||
"character_ids", uint16ListLog(removed))
|
||||
}
|
||||
}
|
||||
|
||||
// CompanionForceEnter drives the ordinary op16 dungeon entry for an online
|
||||
// character without requiring the client to send op16. It exists for the
|
||||
// headless-party PoC: the client's party window can be stuck on its P2P gate
|
||||
// even though the server party is complete, which blocks the only packet that
|
||||
// would start the entry.
|
||||
func (s *Service) CompanionForceEnter(characterID uint16, dungeonID uint32) error {
|
||||
if s == nil || characterID == 0 || dungeonID == 0 {
|
||||
return fmt.Errorf("companion force enter needs character and dungeon")
|
||||
}
|
||||
session, online := s.onlineGameSession(characterID)
|
||||
if !online || session == nil {
|
||||
return fmt.Errorf("character %d is not online", characterID)
|
||||
}
|
||||
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], dungeonID)
|
||||
body[4] = 0 // difficulty
|
||||
binary.LittleEndian.PutUint16(body[5:7], 0)
|
||||
body[7] = 0 // selection mode
|
||||
body[8] = 0 // runtime state
|
||||
binary.LittleEndian.PutUint16(body[9:11], 0)
|
||||
binary.LittleEndian.PutUint32(body[11:15], 0)
|
||||
body[15] = 0 // the current 21-byte writer rejects a non-zero value here
|
||||
binary.LittleEndian.PutUint32(body[16:20],
|
||||
uint32(currentSceneActorObjectKey(characterID)))
|
||||
body[20] = 0
|
||||
session.companionForceEnter = true
|
||||
defer func() { session.companionForceEnter = false }()
|
||||
s.ensureForceSelectorOrigin(session)
|
||||
headlessDebug(fmt.Sprintf("force_enter character=%d dungeon=%d", characterID, dungeonID))
|
||||
return s.handleDungeonSelectUpper(session, body)
|
||||
}
|
||||
|
||||
func (s *Service) ensureForceSelectorOrigin(session *gameSession) bool {
|
||||
if s == nil || session == nil {
|
||||
return false
|
||||
}
|
||||
session.townMu.Lock()
|
||||
if session.townSelectorOriginBound {
|
||||
session.townMu.Unlock()
|
||||
return true
|
||||
}
|
||||
origin := session.townPositionSnapshot
|
||||
session.townMu.Unlock()
|
||||
// The live town snapshot carries the current area (for example 38/2), while
|
||||
// the dungeon return origin is validated against the durable character
|
||||
// location (38/1). Use the durable ids and only borrow x/y from the live
|
||||
// position.
|
||||
if repos, ok := s.repositoryGroup(); ok && repos.Character != nil {
|
||||
if record, found, err := repos.Character.Load(context.Background(),
|
||||
strconv.FormatUint(uint64(session.selectedCharacterID), 10)); err == nil && found {
|
||||
townID := byte(numericCharacterStatValue(record, "town_id"))
|
||||
areaID := byte(numericCharacterStatValue(record, "area_id"))
|
||||
if townID == 0 {
|
||||
townID = origin.TownID
|
||||
}
|
||||
if areaID == 0 {
|
||||
areaID = origin.AreaID
|
||||
}
|
||||
origin.TownID = townID
|
||||
origin.AreaID = areaID
|
||||
if !origin.PositionValid {
|
||||
origin.PositionX = uint16(numericCharacterStatValue(record, "pos_x"))
|
||||
origin.PositionY = uint16(numericCharacterStatValue(record, "pos_y"))
|
||||
origin.MovementCode = byte(numericCharacterStatValue(record, "direction"))
|
||||
origin.PositionValid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !origin.PositionValid {
|
||||
headlessDebug(fmt.Sprintf("force_origin_unavailable char=%d", session.selectedCharacterID))
|
||||
return false
|
||||
}
|
||||
origin.CharacterID = session.selectedCharacterID
|
||||
session.townMu.Lock()
|
||||
session.townSelectorOriginSnapshot = origin
|
||||
session.townSelectorOriginBound = true
|
||||
session.townMu.Unlock()
|
||||
headlessDebug(fmt.Sprintf("force_origin_bound char=%d town=%d area=%d x=%d y=%d",
|
||||
session.selectedCharacterID, origin.TownID, origin.AreaID, origin.PositionX, origin.PositionY))
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) bindHeadlessSelectorOrigin(leader, follower *gameSession) bool {
|
||||
if s == nil || leader == nil || follower == nil {
|
||||
return false
|
||||
}
|
||||
leader.townMu.Lock()
|
||||
origin := leader.townSelectorOriginSnapshot
|
||||
bound := leader.townSelectorOriginBound
|
||||
leader.townMu.Unlock()
|
||||
if !bound || !origin.PositionValid {
|
||||
headlessDebug(fmt.Sprintf("selector_origin_missing char=%d bound=%v valid=%v",
|
||||
follower.selectedCharacterID, bound, origin.PositionValid))
|
||||
return false
|
||||
}
|
||||
origin.CharacterID = follower.selectedCharacterID
|
||||
follower.townMu.Lock()
|
||||
follower.townSelectorOriginSnapshot = origin
|
||||
follower.townSelectorOriginBound = true
|
||||
follower.townMu.Unlock()
|
||||
headlessDebug(fmt.Sprintf("selector_origin_bound char=%d town=%d area=%d x=%d y=%d",
|
||||
follower.selectedCharacterID, origin.TownID, origin.AreaID, origin.PositionX, origin.PositionY))
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) ensureHeadlessPartyActorProjections(ctx context.Context, leader *gameSession, runtime *runtimeDungeonState) {
|
||||
if s == nil || runtime == nil || runtime.Companion == nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range runtime.Companion.Sources {
|
||||
if !entry.Ready || entry.SourceCharacterID == 0 {
|
||||
continue
|
||||
}
|
||||
identity, online := s.onlineGameSessionCharacterSnapshot(entry.SourceCharacterID)
|
||||
if !online || identity.session == nil || !identity.session.companionHeadless {
|
||||
continue
|
||||
}
|
||||
if _, found := currentPartyActorProjectionSnapshot(
|
||||
identity.session, entry.SourceCharacterID, identity.generation); found {
|
||||
continue
|
||||
}
|
||||
state := entry.ProgressionSnapshot
|
||||
if state.Source.CharacterID == "" {
|
||||
headlessDebug(fmt.Sprintf("actor_projection_missing_state char=%d", entry.SourceCharacterID))
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(state.Source.Name)
|
||||
if name == "" {
|
||||
name = entry.SourceName
|
||||
}
|
||||
ownerChannel := currentPartySceneOwnerContext(identity.session)
|
||||
mode0, err := s.buildCurrentSceneObjectListBodyForSessionInContextStrict(
|
||||
ctx, identity.session, entry.SourceCharacterID, name, state.Source, true, ownerChannel)
|
||||
if err != nil || len(mode0) == 0 {
|
||||
headlessDebug(fmt.Sprintf("actor_projection_mode0_failed char=%d err=%v", entry.SourceCharacterID, err))
|
||||
continue
|
||||
}
|
||||
reader := csharpLegacyUserInfoReader{
|
||||
ctx: ctx,
|
||||
characterID: state.Source.CharacterID,
|
||||
service: s,
|
||||
session: identity.session,
|
||||
suppressActorSkillResources: true,
|
||||
equipmentSnapshot: &state.Equipment,
|
||||
}
|
||||
mode1 := reader.buildCurrentUserInfoMode1WithEquipmentForOwner(
|
||||
state.Source, true, entry.SourceCharacterID, true, 0,
|
||||
currentSceneObjectRoute, ownerChannel)
|
||||
if len(mode1) == 0 {
|
||||
headlessDebug(fmt.Sprintf("actor_projection_mode1_failed char=%d", entry.SourceCharacterID))
|
||||
continue
|
||||
}
|
||||
if err := captureCurrentPartyActorProjection(
|
||||
identity.session, entry.SourceCharacterID, mode0, mode1); err != nil {
|
||||
headlessDebug(fmt.Sprintf("actor_projection_capture_failed char=%d err=%v", entry.SourceCharacterID, err))
|
||||
continue
|
||||
}
|
||||
headlessDebug(fmt.Sprintf("actor_projection_captured char=%d owner=%d mode0=%d mode1=%d",
|
||||
entry.SourceCharacterID, ownerChannel, len(mode0), len(mode1)))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishHeadlessActorToLeader(headless *gameSession, runtime *runtimeDungeonState) bool {
|
||||
if s == nil || headless == nil || runtime == nil || runtime.partyDungeonOwner == nil {
|
||||
return false
|
||||
}
|
||||
leader, online := s.onlineGameSession(runtime.partyDungeonOwner.leaderID)
|
||||
if !online || leader == nil {
|
||||
return false
|
||||
}
|
||||
projection, found := currentPartyActorProjectionSnapshot(
|
||||
headless, headless.selectedCharacterID, headless.characterGeneration)
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
for _, body := range [][]byte{projection.mode0, projection.mode1} {
|
||||
if len(body) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.sendGameUpperRawClass(
|
||||
leader, uint16(dnfenum.CmdPacketSetUDPIPPort), body, 0); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
leader.townMu.Lock()
|
||||
position := leader.townPositionSnapshot
|
||||
leader.townMu.Unlock()
|
||||
x := int(position.PositionX) + int(headless.selectedCharacterID%3)*40
|
||||
y := int(position.PositionY)
|
||||
area := buildCurrentTownUserAreaNotificationBody(
|
||||
currentSceneActorObjectKey(headless.selectedCharacterID),
|
||||
position.TownID, 0xff, uint16(x), uint16(y), position.MovementCode,
|
||||
currentActorDefaultUserStateBits)
|
||||
if err := s.sendGameUpperRawClass(leader, currentTownUserAreaNotificationMsgID, area, 0); err != nil {
|
||||
return false
|
||||
}
|
||||
move := buildCurrentTownUserPositionNotificationBody(
|
||||
currentSceneActorObjectKey(headless.selectedCharacterID),
|
||||
uint16(x), uint16(y), position.MovementCode, 100)
|
||||
if err := s.sendGameUpperRawClass(leader, currentTownUserPositionNotificationMsgID, move, 0); err != nil {
|
||||
return false
|
||||
}
|
||||
headlessDebug(fmt.Sprintf("headless_actor_projected char=%d leader=%d mode0=%d mode1=%d x=%d y=%d",
|
||||
headless.selectedCharacterID, leader.selectedCharacterID, len(projection.mode0), len(projection.mode1), x, y))
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) publishHeadlessActorsBeforeStart(leader *gameSession, runtime *runtimeDungeonState) {
|
||||
if s == nil || leader == nil || runtime == nil || runtime.partyDungeonOwner == nil {
|
||||
return
|
||||
}
|
||||
for _, member := range runtime.partyDungeonOwner.members {
|
||||
identity, online := s.onlineGameSessionCharacterSnapshot(member.userID)
|
||||
if !online || identity.session == nil || !identity.session.companionHeadless ||
|
||||
identity.session.headlessActorPublished {
|
||||
continue
|
||||
}
|
||||
if s.publishHeadlessActorToLeader(identity.session, runtime) {
|
||||
identity.session.headlessActorPublished = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ensureHeadlessCompanionParty(ownerSession *gameSession, ids []uint16) {
|
||||
if s == nil || ownerSession == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
manager := s.runtimePartyManagerForService()
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
ownerIdentity, ok := s.boundGameSessionCharacterSnapshot(ownerSession)
|
||||
if !ok {
|
||||
headlessDebug("party owner_identity_missing")
|
||||
return
|
||||
}
|
||||
ownerMember, ok := s.runtimePartyMemberForIdentity(ownerIdentity)
|
||||
if !ok {
|
||||
headlessDebug(fmt.Sprintf("party owner_member_invalid owner=%d gen=%d", ownerIdentity.character, ownerIdentity.generation))
|
||||
return
|
||||
}
|
||||
snapshot, found := manager.SnapshotByUser(ownerIdentity.character, ownerIdentity.generation)
|
||||
headlessDebug(fmt.Sprintf("party snapshot_before found=%v id=%d members=%d", found, snapshot.ID, len(snapshot.Members)))
|
||||
if !found {
|
||||
created := manager.EnsureLeader(ownerMember, runtimePartyStateSnapshot(ownerSession))
|
||||
if !created.OK {
|
||||
headlessDebug("party ensure_leader_failed reason=" + created.Reason)
|
||||
s.logGameEvent(ownerSession, "game-companion-headless-party-failed",
|
||||
"stage", "leader", "reason", created.Reason)
|
||||
return
|
||||
}
|
||||
snapshot = created.Party
|
||||
} else if len(snapshot.Members) > 1 {
|
||||
// A real party already owns this roster; never mix headless volunteers in.
|
||||
headlessDebug(fmt.Sprintf("party real_party_present members=%d", len(snapshot.Members)))
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
identity, online := s.onlineGameSessionCharacterSnapshot(id)
|
||||
if !online || identity.session == nil || !identity.session.companionHeadless {
|
||||
headlessDebug(fmt.Sprintf("party join_skip id=%d online=%v", id, online))
|
||||
continue
|
||||
}
|
||||
member, memberOK := s.runtimePartyMemberForIdentity(identity)
|
||||
if !memberOK {
|
||||
headlessDebug(fmt.Sprintf("party join_member_invalid id=%d", id))
|
||||
continue
|
||||
}
|
||||
joined := manager.Join(snapshot.ID, member)
|
||||
if !joined.OK {
|
||||
headlessDebug(fmt.Sprintf("party join_failed id=%d reason=%s", id, joined.Reason))
|
||||
s.logGameEvent(ownerSession, "game-companion-headless-party-failed",
|
||||
"stage", "join", "member_id", id, "reason", joined.Reason)
|
||||
continue
|
||||
}
|
||||
snapshot = joined.Party
|
||||
headlessDebug(fmt.Sprintf("party joined id=%d members=%d", id, len(snapshot.Members)))
|
||||
}
|
||||
if len(snapshot.Members) <= 1 {
|
||||
headlessDebug("party no_members_after_join")
|
||||
return
|
||||
}
|
||||
state := s.publishRuntimePartySnapshot(snapshot)
|
||||
headlessDebug(fmt.Sprintf("party published party_id=%d members=%d", state.PartyID, len(snapshot.Members)))
|
||||
if state.PartyID <= 0 {
|
||||
s.logGameEvent(ownerSession, "game-companion-headless-party-failed",
|
||||
"stage", "publish")
|
||||
return
|
||||
}
|
||||
s.logGameEvent(ownerSession, "game-companion-headless-party-online",
|
||||
"owner_character_id", ownerSession.selectedCharacterID,
|
||||
"party_id", state.PartyID,
|
||||
"member_count", len(snapshot.Members))
|
||||
}
|
||||
|
||||
// runHeadlessCompanionAIC is the server-side action driver for one headless
|
||||
// volunteer. The first milestone commits real monster deaths as that character:
|
||||
// the existing op39 owner validates the room actor, applies reward/clear state,
|
||||
// and broadcasts the death to every real party member, so the player sees the
|
||||
// volunteer actually clearing the room. Movement/skill choreography is a later
|
||||
// layer on top of the same driver.
|
||||
func (s *Service) runHeadlessCompanionAIC(session *gameSession) {
|
||||
if s == nil || session == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(1500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if session.selectedCharacterID == 0 {
|
||||
return
|
||||
}
|
||||
session.dungeon.mu.Lock()
|
||||
runtime := session.dungeon.runtime
|
||||
if runtime == nil || runtime.Room == nil || runtime.partyDungeonOwner == nil {
|
||||
session.dungeon.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
snapshot := runtime.Room.Snapshot()
|
||||
session.dungeon.mu.Unlock()
|
||||
|
||||
// A real follower sends op37 FINISH_LOADING after every room load; the
|
||||
// party owner's loading gate waits for every member before it releases
|
||||
// the leader. The headless session has no client, so issue the same
|
||||
// request itself. The handler is idempotent per loading cycle.
|
||||
if !session.headlessActorPublished {
|
||||
if s.publishHeadlessActorToLeader(session, runtime) {
|
||||
session.headlessActorPublished = true
|
||||
}
|
||||
}
|
||||
_ = s.sendFinishLoadingStatus(session, make([]byte, currentFinishLoadingLegacyRequestBodySize))
|
||||
|
||||
var target uint32
|
||||
for _, monster := range snapshot.Monsters {
|
||||
if monster.State == runtimeDungeonMonsterAnnounced {
|
||||
target = monster.ObjectKey
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == 0 {
|
||||
continue
|
||||
}
|
||||
body := make([]byte, dungeoncmd.DieMonsterRequestSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], target)
|
||||
binary.LittleEndian.PutUint16(body[4:6],
|
||||
currentSceneActorObjectKey(session.selectedCharacterID))
|
||||
if err := s.handleDungeonMonsterDeath(session, body); err != nil {
|
||||
s.logGameEvent(session, "game-companion-headless-ai-death-failed",
|
||||
"character_id", session.selectedCharacterID,
|
||||
"object_key", target,
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
s.logGameEvent(session, "game-companion-headless-ai-action",
|
||||
"character_id", session.selectedCharacterID,
|
||||
"action", "kill_monster",
|
||||
"object_key", target,
|
||||
"room", snapshot.Coordinate.String(),
|
||||
"map_id", snapshot.MapID)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user