Files

545 lines
20 KiB
Go

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)
}
}