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