合并本地领先源码:旧UI、GM疲劳燃烧、白金徽章孔、宠物修复、支援兵普攻开关

This commit is contained in:
hanfuit committed 2026-11-04 19:20:49 +08:00
1 parent f2debaf9bf
commit 70b3c5a6dc
57 files changed
+4256 -30

No files matched your search

@@ -0,0 +1,126 @@
package dnf90
import (
"encoding/json"
"io"
"net/http"
"strings"
appkit "longheng.io/server/internal/platform/app"
httpx "longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/services/dnfbridge"
)
// companionPanelAdminPath is the local-only endpoint used by the GM tool's
// "支援兵属性" page. It is intentionally unauthenticated: the admin mux binds
// to loopback, like the existing /local endpoints, and the page is served by
// the local GM tool.
const companionPanelAdminPath = "/local/companion-panel"
func registerCompanionPanelAdmin(env *appkit.Env, bridge *dnfbridge.Service) {
if env == nil || env.AdminMux == nil || bridge == nil {
return
}
env.AdminMux.HandleFunc(companionPanelAdminPath, func(w http.ResponseWriter, r *http.Request) {
applyCompanionPanelCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
switch r.Method {
case http.MethodGet:
characterID := strings.TrimSpace(r.URL.Query().Get("character_id"))
if characterID == "" {
characterID = strings.TrimSpace(r.URL.Query().Get("characterId"))
}
values, found, err := bridge.CompanionPanelValues(r.Context(), characterID)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"character_id": characterID,
"found": found,
"values": values,
})
case http.MethodPost:
var body struct {
CharacterID string `json:"character_id"`
Values map[string]string `json:"values"`
}
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
if err := decoder.Decode(&body); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if strings.TrimSpace(body.CharacterID) == "" {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "character_id is required",
})
return
}
if err := bridge.SaveCompanionPanelValues(r.Context(), body.CharacterID, body.Values); err != nil {
httpx.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
values, found, _ := bridge.CompanionPanelValues(r.Context(), body.CharacterID)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"character_id": body.CharacterID,
"found": found,
"values": values,
})
default:
w.Header().Set("Allow", "GET, POST, OPTIONS")
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "method not allowed",
})
}
})
// Base-estimate generation: PVF growth + worn equipment + weapon forging.
// Enchant cards, amplification, reinforcement, quality, sets, titles, auras
// and pets are intentionally not modelled; the GM page marks the result as
// an estimate and lets the operator correct it.
env.AdminMux.HandleFunc(companionPanelGeneratePath, func(w http.ResponseWriter, r *http.Request) {
applyCompanionPanelCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST, OPTIONS")
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "method not allowed",
})
return
}
var body struct {
CharacterID string `json:"character_id"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
values, err := bridge.GenerateCompanionPanelSnapshot(r.Context(), body.CharacterID)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"character_id": body.CharacterID,
"estimate": true,
"values": values,
})
})
}
const companionPanelGeneratePath = "/local/companion-panel/generate"
func applyCompanionPanelCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Admin-Token")
w.Header().Set("Access-Control-Allow-Private-Network", "true")
}
@@ -0,0 +1,234 @@
package companion
import (
"sort"
"strconv"
"strings"
)
// TeamMaxCompanions is the number of support soldiers that may enter with the
// owner. The current client's ordinary party has four slots: the owner plus
// three companions. Keeping the limit here (rather than in the bridge) makes
// every matcher, GM preflight and status projection agree.
const TeamMaxCompanions = 3
// DefaultTeamAIProfile is the fallback policy for an auto-matched teammate
// that has no per-character GM profile. Team members must actually join the
// fight, so the default is the aggressive AIC variant; operators can still
// override any character through ai_profiles (assist/aggressive).
func DefaultTeamAIProfile(classID uint16) AIProfile {
profile := DefaultAIProfile(classID)
profile.Behavior = "aggressive"
return profile
}
// TeamCandidate is the detached roster row used by the automatic matcher. It
// deliberately contains no session, inventory or mutable character pointers:
// the bridge reads one consistent repository list and passes only identity,
// level, job and online state here.
type TeamCandidate struct {
CharacterID string
Level int
Job string
Slot int
Online bool
Deleted bool
}
// TeamSkip explains why a candidate did not enter the frozen run. It is
// evidence for the GM status and packet log, not a client-facing error.
type TeamSkip struct {
CharacterID string
Reason string
}
// TeamMatch is the deterministic result of one dungeon-entry match.
type TeamMatch struct {
Selected []string
Skipped []TeamSkip
}
const (
TeamSkipOwner = "source_is_owner"
TeamSkipOnline = "source_online"
TeamSkipUnavailable = "source_unavailable"
TeamSkipLevel = "level_unsupported"
TeamSkipDeleted = "source_deleted"
TeamSkipInvalid = "source_invalid"
TeamSkipJob = "job_unsupported"
TeamSkipPartyFull = "party_full"
)
// MatchTeam forms the teammate roster for one dungeon.
//
// Rules, in order:
// 1. The GM's explicit selection is authoritative and keeps its order.
// 2. Remaining slots are filled automatically from the same account's offline,
// non-deleted characters.
// 3. Automatic picks prefer higher level first, then one character per job
// before duplicating a job, then the lower slot/id for stable output.
//
// The result is frozen by the caller. This function is pure so the selection
// cannot depend on map iteration order or on repository timing.
func MatchTeam(ownerID string, configured []string, candidates []TeamCandidate, limit int) TeamMatch {
ownerID = strings.TrimSpace(ownerID)
if limit <= 0 || limit > TeamMaxCompanions {
limit = TeamMaxCompanions
}
byID := make(map[string]TeamCandidate, len(candidates))
for _, candidate := range candidates {
id := strings.TrimSpace(candidate.CharacterID)
if !validCharacterID(id) {
continue
}
if _, exists := byID[id]; exists {
continue
}
candidate.CharacterID = id
candidate.Job = strings.TrimSpace(candidate.Job)
byID[id] = candidate
}
result := TeamMatch{
Selected: make([]string, 0, limit),
Skipped: make([]TeamSkip, 0, 4),
}
selected := make(map[string]struct{}, limit)
skip := func(id, reason string) {
if len(result.Skipped) >= 16 {
return
}
result.Skipped = append(result.Skipped, TeamSkip{CharacterID: id, Reason: reason})
}
selectID := func(id string) bool {
if _, exists := selected[id]; exists {
return true
}
if len(result.Selected) >= limit {
return false
}
selected[id] = struct{}{}
result.Selected = append(result.Selected, id)
return true
}
// Phase 1: preserve the operator's manual choice.
for _, raw := range configured {
id := strings.TrimSpace(raw)
if !validCharacterID(id) {
skip(id, TeamSkipInvalid)
continue
}
if _, exists := selected[id]; exists {
continue
}
if id == ownerID {
skip(id, TeamSkipOwner)
continue
}
candidate, found := byID[id]
if !found {
skip(id, TeamSkipUnavailable)
continue
}
if reason := teamCandidateReason(candidate); reason != "" {
skip(id, reason)
continue
}
if !selectID(id) {
skip(id, TeamSkipPartyFull)
}
}
// Phase 2: auto-fill. Sorting is made total (level, slot, numeric id) so a
// database returning rows in a different order cannot change the roster.
available := make([]TeamCandidate, 0, len(byID))
for id, candidate := range byID {
if id == ownerID {
continue
}
if _, exists := selected[id]; exists {
continue
}
if teamCandidateReason(candidate) != "" {
continue
}
available = append(available, candidate)
}
sort.Slice(available, func(i, j int) bool {
if available[i].Level != available[j].Level {
return available[i].Level > available[j].Level
}
if available[i].Slot != available[j].Slot {
return available[i].Slot < available[j].Slot
}
return teamNumericID(available[i].CharacterID) < teamNumericID(available[j].CharacterID)
})
usedJobs := make(map[string]struct{}, limit)
for id := range selected {
if candidate, ok := byID[id]; ok {
usedJobs[teamJobKey(candidate.Job)] = struct{}{}
}
}
// First pass: one character per job keeps the team varied.
for _, candidate := range available {
if len(result.Selected) >= limit {
break
}
job := teamJobKey(candidate.Job)
if job == "" {
continue
}
if _, exists := usedJobs[job]; exists {
continue
}
if selectID(candidate.CharacterID) {
usedJobs[job] = struct{}{}
}
}
// Second pass: fill the remaining slots by level/slot/id.
for _, candidate := range available {
if len(result.Selected) >= limit {
break
}
if _, exists := selected[candidate.CharacterID]; exists {
continue
}
selectID(candidate.CharacterID)
}
return result
}
func teamCandidateReason(candidate TeamCandidate) string {
if candidate.Deleted {
return TeamSkipDeleted
}
if candidate.Level < 1 || candidate.Level > 90 {
return TeamSkipLevel
}
if candidate.Online {
return TeamSkipOnline
}
if _, err := strconv.ParseUint(strings.TrimSpace(candidate.Job), 10, 8); err != nil {
return TeamSkipJob
}
return ""
}
func teamJobKey(job string) string {
parsed, err := strconv.ParseUint(strings.TrimSpace(job), 10, 8)
if err != nil {
return ""
}
return strconv.FormatUint(parsed, 10)
}
func teamNumericID(id string) int {
parsed, err := strconv.Atoi(strings.TrimSpace(id))
if err != nil {
return int(^uint(0) >> 1)
}
return parsed
}
@@ -0,0 +1,95 @@
package companion
import (
"reflect"
"testing"
)
func TestMatchTeamKeepsManualSelectionAndFillsByLevelAndJob(t *testing.T) {
candidates := []TeamCandidate{
{CharacterID: "1", Level: 90, Job: "0", Slot: 0},
{CharacterID: "2", Level: 90, Job: "12", Slot: 1},
{CharacterID: "3", Level: 90, Job: "3", Slot: 2},
{CharacterID: "4", Level: 90, Job: "0", Slot: 3},
{CharacterID: "5", Level: 21, Job: "2", Slot: 4},
}
match := MatchTeam("1", []string{"4"}, candidates, 3)
if want := []string{"4", "2", "3"}; !reflect.DeepEqual(match.Selected, want) {
t.Fatalf("selected=%v want=%v", match.Selected, want)
}
}
func TestMatchTeamSkipsOwnerOnlineDeletedLowLevelAndInvalid(t *testing.T) {
candidates := []TeamCandidate{
{CharacterID: "1", Level: 90, Job: "0", Slot: 0}, // owner
{CharacterID: "2", Level: 90, Job: "0", Slot: 1, Online: true}, // online
{CharacterID: "3", Level: 90, Job: "0", Slot: 2, Deleted: true},
{CharacterID: "4", Level: 0, Job: "0", Slot: 3},
{CharacterID: "5", Level: 90, Job: "x", Slot: 4},
{CharacterID: "0", Level: 90, Job: "0", Slot: 5},
{CharacterID: "6", Level: 90, Job: "1", Slot: 6},
}
match := MatchTeam("1", []string{"2", "3", "4", "5", "0", "99"}, candidates, 3)
if want := []string{"6"}; !reflect.DeepEqual(match.Selected, want) {
t.Fatalf("selected=%v want=%v", match.Selected, want)
}
assertSkip(t, match, "2") // online
assertSkip(t, match, "3") // deleted
assertSkip(t, match, "4") // low level
assertSkip(t, match, "5") // unsupported job
assertSkip(t, match, "99") // missing
}
func TestMatchTeamCapsAtThreeAndIsDeterministic(t *testing.T) {
candidates := []TeamCandidate{
{CharacterID: "9", Level: 90, Job: "0", Slot: 9},
{CharacterID: "7", Level: 90, Job: "2", Slot: 7},
{CharacterID: "8", Level: 90, Job: "4", Slot: 8},
{CharacterID: "6", Level: 90, Job: "1", Slot: 6},
}
first := MatchTeam("1", nil, candidates, 99)
if len(first.Selected) != TeamMaxCompanions {
t.Fatalf("selected=%v want %d entries", first.Selected, TeamMaxCompanions)
}
// Slot order wins inside the same-levelled, different-job set.
if want := []string{"6", "7", "8"}; !reflect.DeepEqual(first.Selected, want) {
t.Fatalf("selected=%v want=%v", first.Selected, want)
}
for i := 0; i < 20; i++ {
again := MatchTeam("1", nil, candidates, 99)
if !reflect.DeepEqual(again.Selected, first.Selected) {
t.Fatalf("iteration %d selected=%v want=%v", i, again.Selected, first.Selected)
}
}
}
func TestMatchTeamEmptyRosterReturnsNoSelection(t *testing.T) {
match := MatchTeam("1", []string{"2"}, nil, 3)
if len(match.Selected) != 0 {
t.Fatalf("selected=%v want empty", match.Selected)
}
assertSkip(t, match, "2")
}
func TestDefaultTeamAIProfileIsAggressiveAndJobScoped(t *testing.T) {
profile := DefaultTeamAIProfile(12)
if profile.Behavior != "aggressive" {
t.Fatalf("behavior=%q want aggressive", profile.Behavior)
}
if profile.ClassID != 12 {
t.Fatalf("class id=%d want 12", profile.ClassID)
}
if err := profile.Validate(); err != nil {
t.Fatalf("default team profile is invalid: %v", err)
}
}
func assertSkip(t *testing.T, match TeamMatch, id string) {
t.Helper()
for _, skipped := range match.Skipped {
if skipped.CharacterID == id {
return
}
}
t.Fatalf("candidate %s was not recorded as skipped: %+v", id, match.Skipped)
}
@@ -10,8 +10,8 @@ const (
boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1"
boundMovieBytes = int64(22846532)
boundDllSHA256 = "CF2A0C4626DF2A5D056DC87CFB3D6AA07BC7A4EF7888B5B3B80A006BAD2204AF"
boundDllBytes = int64(1229824)
boundDllSHA256 = "F6DDA53482B6B8F6A3FD155FF0A15EB590C5AFF9572140004242793DC957561C"
boundDllBytes = int64(1230336)
)
@@ -63,6 +63,16 @@ type currentDungeonCompanionRun struct {
Enabled bool
BalanceEnabled bool
RandomEquipmentEnabled bool
TeamMode bool
TeamLeaderID uint16
TeamSize int
Online bool
OnlineAt time.Time
OfflineAt time.Time
OfflineReason string
LogoutSent bool
NativePartyID uint16
NativePartySent bool
Ready bool
Reason string
ReasonMessage string
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"os"
"sort"
"strconv"
@@ -369,6 +370,22 @@ func mustProjectionCharacterID(value string) uint64 {
return parsed
}
// buildCurrentDungeonCompanionOfflineBody is the exact present=0 projection
// body. The client's decoder requires all identity/count fields to be zero,
// the body length to be exactly the 72-byte header, and a non-zero revision.
// One body clears the whole squad, so the caller sends it once per run.
func buildCurrentDungeonCompanionOfflineBody(revision uint64) []byte {
if revision == 0 {
revision = 1
}
body := make([]byte, currentDungeonCompanionProjectionHeaderSize)
binary.LittleEndian.PutUint32(body[0:4], currentDungeonCompanionProjectionMagic)
body[4] = currentDungeonCompanionProjectionVersion
body[5] = 0
binary.LittleEndian.PutUint64(body[8:16], revision)
return body
}
func (s *Service) currentDungeonCompanionProjectionSourceID(
ctx context.Context,
session *gameSession,
@@ -531,6 +548,22 @@ func (s *Service) sendCurrentDungeonCompanionProjection(
"private_opcode", currentDungeonCompanionProjectionOpcode,
"body_len", len(body),
"template_mutation", false)
return s.sendGameUpperRawClass(
session, currentDungeonCompanionProjectionOpcode, body, 0)
if err := s.sendGameUpperRawClass(
session, currentDungeonCompanionProjectionOpcode, body, 0); err != nil {
return err
}
// The dungeon is now loaded on the client (projection is sent after op29 and
// player placement), so a notice here is visible instead of being swallowed
// by the loading screen.
if repos.Settings != nil {
if snapshot, found := loadCompanionPanelSnapshot(ctx, repos,
strconv.FormatUint(uint64(sourceID), 10)); found {
_ = s.sendCurrentClientNotice(session,
fmt.Sprintf("支援兵面板 HP=%d MP=%d 力量=%d 物攻=%d 物防=%d",
snapshot.HPMax, snapshot.MPMax, snapshot.Strength,
snapshot.PhysicalAttack, snapshot.PhysicalDefense),
currentClientNoticeDefaultColor, "companion-panel-summary")
}
}
return nil
}
@@ -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)
}
}
@@ -0,0 +1,430 @@
package dnfbridge
import (
"context"
"os"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/party"
dnffriend "longheng.io/server/internal/modules/dnf/unitedfriend"
)
// Volunteers are persistent social characters: the native friend list and the
// native party frame both resolve them through a synthetic presence session.
// The session has no socket; it exists only while the server process is up so
// the operator's friend window keeps seeing the volunteers as online, including
// after a dungeon ends.
const (
currentCompanionNativePartyEnv = "DNF_COMPANION_NATIVE_PARTY"
// Presence of this file disables the native op9/op153 frame without a
// restart. The friend presence stays online; only the party window is
// suppressed, which is the fast rollback if a client build rejects the
// synthetic roster.
currentCompanionNativePartyDisableFile = "companion_native_party.disabled"
currentCompanionVirtualPrefix = "companion-presence-"
)
func currentCompanionNativePartyEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionNativePartyEnv))) {
case "0", "off", "false", "no":
return false
}
if _, err := os.Stat(currentCompanionNativePartyDisableFile); err == nil {
return false
}
return true
}
func (s *Service) companionPresenceSession(characterID uint16) (*gameSession, bool) {
if s == nil || characterID == 0 {
return nil, false
}
// A real client always wins over a synthetic presence row.
if real, online := s.onlineGameSession(characterID); online && real != nil {
return real, false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
session := s.companionSessions[characterID]
return session, session != nil
}
// companionPresenceFriendChannel returns the friend-row channel for a synthetic
// volunteer. It copies the field under companionPresenceMu because ensure may
// update the channel when the owner moves.
func (s *Service) companionPresenceFriendChannel(characterID uint16) (uint16, bool) {
if s == nil || characterID == 0 {
return 0, false
}
if real, online := s.onlineGameSession(characterID); online && real != nil {
return 0, false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
session := s.companionSessions[characterID]
if session == nil {
return 0, false
}
return uint16(session.channel.ID), true
}
func (s *Service) companionSessionSnapshot(characterID uint16) (boundGameSessionCharacter, bool) {
session, ok := s.companionPresenceSession(characterID)
if !ok || session == nil || session.companionVirtual == false {
return boundGameSessionCharacter{}, false
}
return boundGameSessionCharacter{
session: session,
character: characterID,
generation: session.characterGeneration,
virtual: true,
}, true
}
func (s *Service) companionSessionCurrent(identity boundGameSessionCharacter) bool {
if s == nil || identity.session == nil || identity.character == 0 {
return false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
return s.companionSessions[identity.character] == identity.session &&
identity.session.selectedCharacterID == identity.character &&
identity.session.characterGeneration == identity.generation
}
func (s *Service) nextCompanionGeneration() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
s.nextGameSessionGeneration++
if s.nextGameSessionGeneration == 0 {
s.nextGameSessionGeneration++
}
return s.nextGameSessionGeneration
}
// ensureCompanionPresenceForSession makes the currently configured volunteers
// visible in the native friend window for this owner. It is called at the login
// scene tail (before op273) and again at dungeon entry.
func (s *Service) ensureCompanionPresenceForSession(ctx context.Context, session *gameSession) {
if s == nil || session == nil || session.selectedCharacterID == 0 ||
!currentDungeonCompanionTeamModeEnabled() {
return
}
if ctx == nil {
ctx = context.Background()
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil || repos.Settings == nil {
return
}
ownerID := session.selectedCharacterID
ownerKey := strconv.FormatUint(uint64(ownerID), 10)
cfg, _, _, err := s.loadCurrentDungeonCompanionConfig(
ctx, ownerKey, strings.TrimSpace(s.accountIDForSession(session)), repos)
if err != nil {
s.logGameEvent(session, "game-companion-presence-config-failed",
"owner_character_id", ownerID, "error", err)
return
}
if !cfg.Enabled {
s.dropCompanionPresenceForOwner(ownerID)
return
}
sources := cfg.Sources()
accountID := strings.TrimSpace(s.accountIDForSession(session))
if accountID == "" {
owner, found, loadErr := repos.Character.Load(ctx, ownerKey)
if loadErr != nil || !found {
return
}
accountID = strings.TrimSpace(owner.AccountID)
}
matched, _, applied := s.autoMatchCurrentDungeonCompanionSources(
ctx, repos, accountID, ownerID, sources)
if applied {
sources = matched
}
if len(sources) == 0 {
return
}
ownerName := ""
if _, name, _, found := s.selectedCharacterForEnter(ctx, session); found {
ownerName = name
}
s.ensureCompanionFriendRelations(ctx, session, ownerName, sources)
if currentCompanionHeadlessEnabled() {
ids := s.ensureHeadlessCompanionSessions(ctx, session, sources, accountID)
s.ensureHeadlessCompanionParty(session, ids)
} else {
s.dropHeadlessCompanionSessionsForOwner(ownerID)
s.ensureCompanionVirtualSessions(ctx, session, sources, accountID)
}
s.logGameEvent(session, "game-companion-presence-online",
"owner_character_id", ownerID,
"source_character_ids", strings.Join(sources, ","),
"count", len(sources))
}
func (s *Service) ensureCompanionVirtualSessions(
ctx context.Context,
ownerSession *gameSession,
sources []string,
accountID string,
) {
if s == nil || ownerSession == nil || len(sources) == 0 {
return
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
return
}
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerSession.selectedCharacterID {
continue
}
if _, realOnline := s.onlineGameSession(id); realOnline {
continue
}
character, found, err := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil || !found || character.Stats["delete_flag"] != 0 ||
strings.TrimSpace(character.Name) == "" {
continue
}
// Allocate the generation before taking companionPresenceMu: the
// generation owner lives under Service.mu, and bindGameSessionCharacter
// holds Service.mu while dropping a virtual session.
generation := s.nextCompanionGeneration()
s.companionPresenceMu.Lock()
if s.companionSessions == nil {
s.companionSessions = make(map[uint16]*gameSession)
}
session := s.companionSessions[id]
if session == nil {
session = &gameSession{
connID: currentCompanionVirtualPrefix + strconv.FormatUint(uint64(id), 10),
accountID: accountID,
selectedCharacterID: id,
characterGeneration: generation,
channel: ownerSession.channel,
companionVirtual: true,
companionOwnerID: ownerSession.selectedCharacterID,
}
s.companionSessions[id] = session
} else {
session.channel = ownerSession.channel
session.companionOwnerID = ownerSession.selectedCharacterID
session.accountID = accountID
}
s.companionPresenceMu.Unlock()
}
}
func (s *Service) ensureCompanionFriendRelations(
ctx context.Context,
session *gameSession,
ownerName string,
sources []string,
) {
if s == nil || session == nil || strings.TrimSpace(ownerName) == "" {
return
}
repos, ok := s.repositoryGroup()
if !ok || repos.UnitedFriend == nil || repos.Character == nil {
return
}
owner, err := dnffriend.NewOwner(repos, nil)
if err != nil {
return
}
ownerID := session.selectedCharacterID
s.friendMu.Lock()
defer s.friendMu.Unlock()
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerID {
continue
}
character, found, loadErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10))
if loadErr != nil || !found || strings.TrimSpace(character.Name) == "" {
continue
}
if _, addErr := owner.Add(ctx, ownerName, ownerID, character.Name, id); addErr != nil {
if addErr != dnffriend.ErrFull {
s.logGameEvent(session, "game-companion-friend-add-failed",
"owner_character_id", ownerID,
"source_character_id", id,
"error", addErr)
}
continue
}
s.logGameEvent(session, "game-companion-friend-ensured",
"owner_character_id", ownerID,
"source_character_id", id,
"name", character.Name)
}
}
func (s *Service) dropCompanionPresenceCharacter(characterID uint16) {
if s == nil || characterID == 0 {
return
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
delete(s.companionSessions, characterID)
}
func (s *Service) dropCompanionPresenceForOwner(ownerID uint16) {
if s == nil || ownerID == 0 {
return
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
for id, session := range s.companionSessions {
if session != nil && session.companionOwnerID == ownerID {
delete(s.companionSessions, id)
}
}
}
func companionPartyID(ownerID uint16) uint16 {
id := uint16(0xE000) | (ownerID & 0x0FFF)
if id == 0 || id == 0xFFFF {
id = 0xE001
}
return id
}
func (s *Service) buildCompanionPartyState(session *gameSession, run *currentDungeonCompanionRun) alignedcmd.PartyState {
if s == nil || session == nil || run == nil {
return alignedcmd.PartyState{}
}
state := alignedcmd.PartyState{
PartyID: int(companionPartyID(session.selectedCharacterID)),
IsLeader: true,
UserID: session.selectedCharacterID,
UserState: 100,
MaxMembers: 4,
}
state.Members = append(state.Members, alignedcmd.PartyMemberState{
UserID: session.selectedCharacterID, Slot: 0, UserState: 100, HPPercent: 100, MPPercent: 100,
})
slot := byte(1)
for _, entry := range run.Sources {
if !entry.Ready || entry.SourceCharacterID == 0 {
continue
}
if slot >= state.MaxMembers {
break
}
state.Members = append(state.Members, alignedcmd.PartyMemberState{
UserID: entry.SourceCharacterID, Slot: slot, UserState: 100, HPPercent: 100, MPPercent: 100,
})
slot++
}
return state
}
// publishCurrentDungeonCompanionNativeParty builds the native op9 party frame
// (kind-0 complete roster) followed by op153 HP/MP rows. It intentionally does
// not send op11: the volunteers have no P2P socket, and a made-up endpoint
// would leave the client stuck on "connecting". The scene actors themselves
// still come from the support-soldier projection.
func (s *Service) publishCurrentDungeonCompanionNativeParty(session *gameSession, runtime *runtimeDungeonState) {
if s == nil || session == nil || runtime == nil || runtime.Companion == nil ||
currentCompanionHeadlessEnabled() ||
!currentCompanionNativePartyEnabled() {
return
}
run := runtime.Companion
if !run.Ready || len(run.Sources) == 0 {
return
}
currentParty := runtimePartyStateSnapshot(session)
if currentParty.PartyID > 0 && len(runtimePartyMembers(currentParty)) > 1 {
s.logGameEvent(session, "game-companion-native-party-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "owner_already_in_native_party")
return
}
if runtime.partyDungeonOwner != nil || isCurrentImplementedRaidChannel(session) {
s.logGameEvent(session, "game-companion-native-party-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "party_or_raid_owner_has_priority")
return
}
sources := make([]string, 0, len(run.Sources))
for _, entry := range run.Sources {
if entry.Ready && entry.SourceCharacterID != 0 {
sources = append(sources, strconv.FormatUint(uint64(entry.SourceCharacterID), 10))
}
}
s.ensureCompanionVirtualSessions(
context.Background(), session, sources, strings.TrimSpace(s.accountIDForSession(session)))
state := s.buildCompanionPartyState(session, run)
if len(state.Members) <= 1 {
return
}
sent, err := s.sendCurrentPartyActorFrameProjection(
session, session, state, "companion_native_party")
if err != nil {
s.logGameEvent(session, "game-companion-native-party-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"error", err)
return
}
if !sent {
s.logGameEvent(session, "game-companion-native-party-deferred",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"reason", "op9_projection_not_delivered")
return
}
if err := s.sendRuntimePartyRealtimeInfoLocal(session, state); err != nil {
s.logGameEvent(session, "game-companion-native-party-rows-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"error", err)
return
}
run.NativePartyID = uint16(state.PartyID)
run.NativePartySent = true
s.logGameEvent(session, "game-companion-native-party-sent",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"member_count", len(state.Members),
"source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run)))
}
// clearCurrentDungeonCompanionNativeParty empties only the party window; the
// virtual sessions and friend rows stay online, which is the requested
// post-dungeon state.
func (s *Service) clearCurrentDungeonCompanionNativeParty(session *gameSession, run *currentDungeonCompanionRun) {
if s == nil || session == nil || run == nil || !run.NativePartySent || run.NativePartyID == 0 {
return
}
body := buildCurrentSceneOp9ActorRemovalBodyInContext(
run.NativePartyID, currentPartySceneOwnerContext(session))
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRecoverStamina), body, 0); err != nil {
s.logGameEvent(session, "game-companion-native-party-clear-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"error", err)
}
if err := s.sendGameUpperRawClassCodec(
session, 0x0099, party.BuildEmptyRealtimeInfo(), 0, true); err != nil {
s.logGameEvent(session, "game-companion-native-party-clear-rows-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"error", err)
}
run.NativePartySent = false
s.logGameEvent(session, "game-companion-native-party-cleared",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"friends_online", true)
}
@@ -0,0 +1,129 @@
package dnfbridge
import (
"bytes"
"encoding/binary"
"net"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
)
type companionPartyTestConn struct {
net.Conn
write bytes.Buffer
}
func (c *companionPartyTestConn) Write(data []byte) (int, error) { return c.write.Write(data) }
func (c *companionPartyTestConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestCompanionPresenceFriendRowRendersOnline(t *testing.T) {
service := &Service{}
virtual := &gameSession{
selectedCharacterID: 6,
characterGeneration: 7,
channel: channelcatalog.Channel{ID: 11},
companionVirtual: true,
companionOwnerID: 2,
}
service.companionSessions = map[uint16]*gameSession{6: virtual}
presence := service.currentUnitedFriendPresence(6)
if presence.Busy {
t.Fatal("volunteer friend row must not render busy/登录中")
}
if presence.ChannelID != 11 {
t.Fatalf("friend row channel=%d want 11", presence.ChannelID)
}
}
func TestBuildCompanionPartyStateUsesNativeFourSlotRoster(t *testing.T) {
service := &Service{}
owner := &gameSession{selectedCharacterID: 2}
run := &currentDungeonCompanionRun{
Ready: true,
Sources: []currentDungeonCompanionSourceRun{
{Ready: true, SourceCharacterID: 6},
{Ready: false, SourceCharacterID: 7},
{Ready: true, SourceCharacterID: 3},
},
}
state := service.buildCompanionPartyState(owner, run)
if state.PartyID <= 0 || state.PartyID >= 0xFFFF {
t.Fatalf("party id=%d is not a printable native party", state.PartyID)
}
if state.UserID != 2 || !state.IsLeader || state.MaxMembers != 4 {
t.Fatalf("party identity=%+v", state)
}
if len(state.Members) != 3 {
t.Fatalf("members=%v want owner + 2 ready volunteers", state.Members)
}
want := []struct {
id uint16
slot byte
}{{2, 0}, {6, 1}, {3, 2}}
for index, expected := range want {
member := state.Members[index]
if member.UserID != expected.id || member.Slot != expected.slot {
t.Fatalf("member %d=%+v want id=%d slot=%d", index, member, expected.id, expected.slot)
}
if member.HPPercent != 100 || member.MPPercent != 100 {
t.Fatalf("member %d hp/mp=%d/%d want 100/100", index, member.HPPercent, member.MPPercent)
}
}
}
func TestClearCompanionNativePartySendsOp9ThenOp153(t *testing.T) {
connection := &companionPartyTestConn{}
session := &gameSession{
conn: connection,
connID: "companion-party-clear",
selectedCharacterID: 2,
characterGeneration: 3,
channel: channelcatalog.Channel{ID: 11},
}
run := &currentDungeonCompanionRun{
Ready: true,
NativePartyID: 0xE002,
NativePartySent: true,
}
service := &Service{}
service.clearCurrentDungeonCompanionNativeParty(session, run)
if run.NativePartySent {
t.Fatal("native party flag was not cleared")
}
stream := connection.write.Bytes()
headerSize := service.gameUpperHeaderSize()
msgIDs := make([]uint16, 0, 2)
for len(stream) > 0 {
if len(stream) < headerSize {
t.Fatalf("truncated packet header: %d", len(stream))
}
total := int(binary.LittleEndian.Uint32(stream[3:7]))
if total < headerSize || total > len(stream) {
t.Fatalf("packet total=%d stream=%d", total, len(stream))
}
msgIDs = append(msgIDs, binary.LittleEndian.Uint16(stream[1:3]))
stream = stream[total:]
}
if len(msgIDs) != 2 || msgIDs[0] != 9 || msgIDs[1] != 0x0099 {
t.Fatalf("clear packets=%v want [9 153]", msgIDs)
}
}
func TestCompanionSessionIdentityIsCurrentOnlyWhileRegistered(t *testing.T) {
service := &Service{}
virtual := &gameSession{selectedCharacterID: 6, characterGeneration: 9, companionVirtual: true}
service.companionSessions = map[uint16]*gameSession{6: virtual}
identity, ok := service.companionSessionSnapshot(6)
if !ok || !identity.virtual || !service.boundGameSessionCharacterCurrent(identity) {
t.Fatalf("virtual identity=%+v ok=%v current=%v", identity, ok, service.boundGameSessionCharacterCurrent(identity))
}
delete(service.companionSessions, 6)
if service.boundGameSessionCharacterCurrent(identity) {
t.Fatal("removed volunteer session must fail the liveness check")
}
}
@@ -0,0 +1,306 @@
package dnfbridge
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
dnfcharstat "longheng.io/server/internal/modules/dnf/charstat"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Companion panel snapshot (Option B): the client captures the source
// character's final character-info panel values and stores them in a settings
// record; the support-soldier projection prefers this snapshot over the
// PVF-derived base vector so the companion can mirror the source panel.
const (
companionPanelSnapshotKeyPrefix = "character:"
companionPanelSnapshotKeySuffix = ":companion-panel-v1"
)
var errCompanionPanelCharacterID = errors.New("companion panel: character_id is required")
type companionPanelSnapshot struct {
HPMax int64
MPMax int64
Strength int64
Intelligence int64
Vitality int64
Spirit int64
PhysicalAttack int64
MagicalAttack int64
IndependentAttack int64
PhysicalDefense int64
MagicalDefense int64
FireResistance int64
WaterResistance int64
DarkResistance int64
LightResistance int64
MoveSpeed int64
AttackSpeed int64
CastSpeed int64
HitRecovery int64
JumpPower int64
Weight int64
InventoryLimit int64
HPRegenSpeed int64
MPRegenSpeed int64
SourceClient string
// The following values are shown on the character-info panel but are not
// part of the 92-byte companion mode1 blob; they are stored so the GM panel
// can mirror the full panel and future projections can consume them.
// Percentages are stored scaled by 10 (70.0% -> 700); the three speed fields
// above use the blob's scale of 100 (60.50% -> 6050).
PhysicalCritical int64
MagicalCritical int64
MagicResistance int64
HitRate int64
DodgeRate int64
Stiffness int64
FireDamage int64
WaterDamage int64
LightDamage int64
DarkDamage int64
}
func companionPanelSnapshotScope(characterID string) string {
return companionPanelSnapshotKeyPrefix + strings.TrimSpace(characterID) +
companionPanelSnapshotKeySuffix
}
func companionPanelSnapshotInt(values map[string]string, names ...string) int64 {
for _, name := range names {
raw, ok := values[name]
if !ok {
continue
}
parsed, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err == nil {
return parsed
}
}
return 0
}
func loadCompanionPanelSnapshot(
ctx context.Context,
repos dnfrepo.Group,
characterID string,
) (companionPanelSnapshot, bool) {
if repos.Settings == nil || strings.TrimSpace(characterID) == "" {
return companionPanelSnapshot{}, false
}
record, found, err := repos.Settings.Load(
ctx, companionPanelSnapshotScope(characterID))
if err != nil || !found {
return companionPanelSnapshot{}, false
}
values := record.Values
snapshot := companionPanelSnapshot{
HPMax: companionPanelSnapshotInt(values, "hp_max"),
MPMax: companionPanelSnapshotInt(values, "mp_max"),
Strength: companionPanelSnapshotInt(values, "strength"),
Intelligence: companionPanelSnapshotInt(values, "intelligence"),
Vitality: companionPanelSnapshotInt(values, "vitality"),
Spirit: companionPanelSnapshotInt(values, "spirit"),
PhysicalAttack: companionPanelSnapshotInt(values, "physical_attack"),
MagicalAttack: companionPanelSnapshotInt(values, "magical_attack"),
IndependentAttack: companionPanelSnapshotInt(values, "independent_attack"),
PhysicalDefense: companionPanelSnapshotInt(values, "physical_defense"),
MagicalDefense: companionPanelSnapshotInt(values, "magical_defense"),
FireResistance: companionPanelSnapshotInt(values, "fire_resistance"),
WaterResistance: companionPanelSnapshotInt(values, "water_resistance"),
DarkResistance: companionPanelSnapshotInt(values, "dark_resistance"),
LightResistance: companionPanelSnapshotInt(values, "light_resistance"),
MoveSpeed: companionPanelSnapshotInt(values, "move_speed"),
AttackSpeed: companionPanelSnapshotInt(values, "attack_speed"),
CastSpeed: companionPanelSnapshotInt(values, "cast_speed"),
HitRecovery: companionPanelSnapshotInt(values, "hit_recovery"),
JumpPower: companionPanelSnapshotInt(values, "jump_power"),
Weight: companionPanelSnapshotInt(values, "weight"),
InventoryLimit: companionPanelSnapshotInt(values, "inventory_limit"),
HPRegenSpeed: companionPanelSnapshotInt(values, "hp_regen"),
MPRegenSpeed: companionPanelSnapshotInt(values, "mp_regen"),
SourceClient: strings.TrimSpace(values["source_client"]),
PhysicalCritical: companionPanelSnapshotInt(values, "physical_critical"),
MagicalCritical: companionPanelSnapshotInt(values, "magical_critical"),
MagicResistance: companionPanelSnapshotInt(values, "magic_resistance"),
HitRate: companionPanelSnapshotInt(values, "hit_rate"),
DodgeRate: companionPanelSnapshotInt(values, "dodge_rate"),
Stiffness: companionPanelSnapshotInt(values, "stiffness"),
FireDamage: companionPanelSnapshotInt(values, "fire_damage"),
WaterDamage: companionPanelSnapshotInt(values, "water_damage"),
LightDamage: companionPanelSnapshotInt(values, "light_damage"),
DarkDamage: companionPanelSnapshotInt(values, "dark_damage"),
}
if snapshot.HPMax <= 0 || snapshot.MPMax <= 0 || snapshot.Strength <= 0 {
return companionPanelSnapshot{}, false
}
return snapshot, true
}
func (s companionPanelSnapshot) Vector() dnfcharstat.Vector {
return dnfcharstat.Vector{
HPMax: s.HPMax,
MPMax: s.MPMax,
Strength: s.Strength,
Intelligence: s.Intelligence,
Vitality: s.Vitality,
Spirit: s.Spirit,
PhysicalAttack: s.PhysicalAttack,
PhysicalDefense: s.PhysicalDefense,
MagicalAttack: s.MagicalAttack,
MagicalDefense: s.MagicalDefense,
IndependentAttack: s.IndependentAttack,
FireResistance: s.FireResistance,
WaterResistance: s.WaterResistance,
DarkResistance: s.DarkResistance,
LightResistance: s.LightResistance,
InventoryLimit: s.InventoryLimit,
HPRegenSpeed: s.HPRegenSpeed,
MPRegenSpeed: s.MPRegenSpeed,
MoveSpeed: s.MoveSpeed,
AttackSpeed: s.AttackSpeed,
CastSpeed: s.CastSpeed,
HitRecovery: s.HitRecovery,
JumpPower: s.JumpPower,
Weight: s.Weight,
}
}
// CompanionPanelValues returns the raw stored panel snapshot values for the GM
// editor. found is false when no snapshot has been saved yet.
func (s *Service) CompanionPanelValues(ctx context.Context, characterID string) (map[string]string, bool, error) {
repos, ok := s.repositoryGroup()
if !ok {
return nil, false, ErrGMRepositoryUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return nil, false, errCompanionPanelCharacterID
}
record, found, err := repos.Settings.Load(ctx, companionPanelSnapshotScope(characterID))
if err != nil {
return nil, false, err
}
if !found {
return map[string]string{}, false, nil
}
values := make(map[string]string, len(record.Values))
nonEmpty := false
for k, v := range record.Values {
values[k] = v
if strings.TrimSpace(v) != "" {
nonEmpty = true
}
}
// A record whose values are all empty is a leftover of a blank GM save and
// carries no usable panel; report it as not configured so the editor does
// not show a misleading "已读取".
if !nonEmpty {
return map[string]string{}, false, nil
}
return values, true, nil
}
// SaveCompanionPanelValues replaces the stored panel snapshot values.
func (s *Service) SaveCompanionPanelValues(ctx context.Context, characterID string, values map[string]string) error {
repos, ok := s.repositoryGroup()
if !ok {
return ErrGMRepositoryUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return errCompanionPanelCharacterID
}
cleaned := make(map[string]string, len(values))
for k, v := range values {
key := strings.TrimSpace(k)
if key == "" {
continue
}
cleaned[key] = strings.TrimSpace(v)
}
return repos.Settings.Save(ctx, dnfrepo.SettingsRecord{
Scope: companionPanelSnapshotScope(characterID),
Values: cleaned,
})
}
// GenerateCompanionPanelSnapshot builds a best-effort panel snapshot from the
// server's PVF character growth plus the character's worn equipment PVF stats
// (and weapon forging). It deliberately does not model enchant cards,
// amplification, reinforcement, quality, set effects, titles, auras or pets:
// the saved values are a BASE ESTIMATE that the GM page can correct.
func (s *Service) GenerateCompanionPanelSnapshot(ctx context.Context, characterID string) (map[string]string, error) {
repos, ok := s.repositoryGroup()
if !ok {
return nil, ErrGMRepositoryUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return nil, errCompanionPanelCharacterID
}
character, found, err := repos.Character.Load(ctx, characterID)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("character %s not found", characterID)
}
base, ok := s.characterPVFStatsForUserInfo(ctx, nil, character, true)
if !ok {
return nil, fmt.Errorf("character %s PVF stats unavailable", characterID)
}
stats := s.addEquipmentPVFStatsForUserInfo(ctx, nil, character, base)
values := companionPanelValuesFromVector(stats, character)
return values, nil
}
func companionPanelValuesFromVector(stats dnfcharstat.Vector, character dnfrepo.CharacterRecord) map[string]string {
values := make(map[string]string, 24)
put := func(key string, value int64) {
values[key] = strconv.FormatInt(value, 10)
}
put("hp_max", stats.HPMax)
put("mp_max", stats.MPMax)
put("strength", stats.Strength)
put("intelligence", stats.Intelligence)
put("vitality", stats.Vitality)
put("spirit", stats.Spirit)
put("physical_attack", stats.PhysicalAttack)
put("magical_attack", stats.MagicalAttack)
put("independent_attack", stats.IndependentAttack)
put("physical_defense", stats.PhysicalDefense)
put("magical_defense", stats.MagicalDefense)
put("fire_resistance", stats.FireResistance)
put("water_resistance", stats.WaterResistance)
put("dark_resistance", stats.DarkResistance)
put("light_resistance", stats.LightResistance)
put("move_speed", stats.MoveSpeed)
put("attack_speed", stats.AttackSpeed)
put("cast_speed", stats.CastSpeed)
put("hit_recovery", stats.HitRecovery)
jump := stats.JumpPower
if jump == 0 {
jump = character.Stats["stat_jump_power"]
}
put("jump_power", jump)
weight := stats.Weight
if weight == 0 {
weight = character.Stats["stat_weight"]
}
put("weight", weight)
inventory := stats.InventoryLimit
if inventory == 0 {
inventory = character.Stats["stat_inventory_limit"]
}
put("inventory_limit", inventory)
put("hp_regen", stats.HPRegenSpeed)
put("mp_regen", stats.MPRegenSpeed)
values["source_client"] = "server-estimate"
return values
}
@@ -0,0 +1,192 @@
package dnfbridge
import (
"context"
"os"
"strings"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
// Real-actor PoC for the volunteer squad.
//
// This path is deliberately switch-gated and OFF by default. It publishes each
// volunteer as a real character actor (mode0 + mode1 + mode3, objectKey =
// character id, non-zero party owner context) instead of the support-soldier
// APC projection. It is the first milestone of the "real character enters the
// dungeon" route; attaching the client AIC to these actors is the next client
// patch step, so this path currently ships appearance/roster only.
const (
currentCompanionRealActorsEnv = "DNF_COMPANION_REAL_ACTORS"
currentCompanionRealActorsFlagFile = "companion_real_actors.enabled"
currentCompanionRealActorsMode0Kind = "real_party_actor"
)
func currentCompanionRealActorsEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionRealActorsEnv))) {
case "0", "off", "false", "no":
return false
}
if _, err := os.Stat(currentCompanionRealActorsFlagFile); err == nil {
return true
}
return false
}
// publishCurrentDungeonCompanionRealActors returns true only when every ready
// volunteer was published as a real actor. A partial failure returns false so
// the caller can keep the proven projection path instead of showing a broken
// half-team.
func (s *Service) publishCurrentDungeonCompanionRealActors(
session *gameSession,
runtime *runtimeDungeonState,
) bool {
if s == nil || session == nil || runtime == nil || runtime.Companion == nil ||
!currentCompanionRealActorsEnabled() {
return false
}
run := runtime.Companion
if !run.Ready || len(run.Sources) == 0 {
return false
}
ownerContext := currentPartySceneOwnerContext(session)
if ownerContext == 0 && session.channel.ID > 0 && session.channel.ID <= 0xff {
ownerContext = byte(session.channel.ID)
}
if ownerContext == 0 {
s.logGameEvent(session, "game-companion-real-actors-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "party_owner_context_not_committed")
return false
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
type realRow struct {
id uint16
name string
mode0 []byte
mode1 []byte
}
rows := make([]realRow, 0, len(run.Sources))
for _, entry := range run.Sources {
if !entry.Ready || entry.SourceCharacterID == 0 {
continue
}
state := entry.ProgressionSnapshot
if state.Source.CharacterID == "" {
return false
}
name := strings.TrimSpace(state.Source.Name)
if name == "" {
name = entry.SourceName
}
mode0, err := s.buildCurrentSceneObjectListBodyForSessionInContextStrict(
ctx, session, entry.SourceCharacterID, name, state.Source, true, ownerContext)
if err != nil || len(mode0) < 3 {
s.logGameEvent(session, "game-companion-real-actor-failed",
"source_character_id", entry.SourceCharacterID,
"stage", "mode0", "error", err)
return false
}
reader := csharpLegacyUserInfoReader{
ctx: ctx,
characterID: state.Source.CharacterID,
service: s,
session: session,
suppressActorSkillResources: true,
equipmentSnapshot: &state.Equipment,
}
mode1 := reader.buildCurrentUserInfoMode1WithEquipmentForOwner(
state.Source, true, entry.SourceCharacterID, true, 0,
currentSceneObjectRoute, ownerContext)
if len(mode1) < 3 {
s.logGameEvent(session, "game-companion-real-actor-failed",
"source_character_id", entry.SourceCharacterID,
"stage", "mode1")
return false
}
rows = append(rows, realRow{id: entry.SourceCharacterID, name: name, mode0: mode0, mode1: mode1})
}
if len(rows) == 0 {
return false
}
// The client's native multi-row reader creates scene actors for a party
// prelude. Sending one row at a time produced global visuals that a dungeon
// room never rendered; the roster must be one counted mode0/mode1 body.
var multiMode0, multiMode1 packetWriter
multiMode0.writeByte(0)
multiMode0.writeUint16(uint16(len(rows)))
multiMode1.writeByte(1)
multiMode1.writeUint16(uint16(len(rows)))
for _, row := range rows {
multiMode0.writeBytes(row.mode0[3:])
multiMode1.writeBytes(row.mode1[3:])
}
for _, body := range [][]byte{multiMode0.bytes(), multiMode1.bytes()} {
if err := s.sendGameUpperRawClass(
session, uint16(dnfenum.CmdPacketSetUDPIPPort), body, 0); err != nil {
s.logGameEvent(session, "game-companion-real-actor-failed",
"stage", "multi-row-send", "error", err)
return false
}
}
session.townMu.Lock()
position := session.townPositionSnapshot
session.townMu.Unlock()
stateBits := currentActorDefaultUserStateBits
if runtime.Character.Stats != nil {
if value, exists := runtime.Character.Stats["user_state_bits"]; exists && value >= 0 && value <= 0xff {
stateBits = byte(value)
}
}
for index, row := range rows {
x := int(position.PositionX) + index*40
y := int(position.PositionY)
if !position.PositionValid {
x = int(numericCharacterStatValue(runtime.Character, "pos_x")) + index*40
y = int(numericCharacterStatValue(runtime.Character, "pos_y"))
}
if x < 0 {
x = 0
}
if x > 0xffff {
x = 0xffff
}
if y < 0 {
y = 0
}
if y > 0xffff {
y = 0xffff
}
areaBody := buildCurrentTownUserAreaNotificationBody(
currentSceneActorObjectKey(row.id),
position.TownID, 0xff, uint16(x), uint16(y),
position.MovementCode, stateBits,
)
if err := s.sendGameUpperRawClass(
session, currentTownUserAreaNotificationMsgID, areaBody, 0); err != nil {
s.logGameEvent(session, "game-companion-real-actor-failed",
"source_character_id", row.id,
"stage", "op23", "error", err)
return false
}
s.logGameEvent(session, "game-companion-real-actor-sent",
"source_character_id", row.id,
"name", row.name,
"owner_context", ownerContext,
"object_key", currentSceneActorObjectKey(row.id),
"mode0_bytes", len(row.mode0),
"mode1_bytes", len(row.mode1),
"path", currentCompanionRealActorsMode0Kind)
}
sent := len(rows)
if sent == 0 {
return false
}
s.logGameEvent(session, "game-companion-real-actors-published",
"owner_character_id", session.selectedCharacterID,
"count", sent,
"party_owner_context", ownerContext,
"aic", "pending_client_attachment")
return true
}
@@ -0,0 +1,268 @@
package dnfbridge
import (
"context"
"encoding/binary"
"os"
"strconv"
"strings"
"time"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// The teammate squad is the default support-soldier entry shape. It can be
// disabled per deployment with DNF_COMPANION_TEAM_MODE=0; the GM settings stay
// authoritative for which characters are preferred, but the server fills the
// remaining slots from the same account at dungeon entry.
const currentDungeonCompanionTeamModeEnv = "DNF_COMPANION_TEAM_MODE"
const currentDungeonCompanionTeamRole = "teammate"
func currentDungeonCompanionTeamModeEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentDungeonCompanionTeamModeEnv))) {
case "0", "off", "false", "no":
return false
default:
return true
}
}
// autoMatchCurrentDungeonCompanionSources expands the operator's saved
// selection into a frozen teammate roster for this dungeon.
//
// This is a read-only matchmaking step: no source character is modified and no
// session is created for it. The actual snapshot/AIC/projection pipeline still
// runs per selected source exactly as before, so a bad or deleted character
// fails on its own row instead of aborting the whole team.
func (s *Service) autoMatchCurrentDungeonCompanionSources(
ctx context.Context,
repos dnfrepo.Group,
accountID string,
ownerCharacterID uint16,
configured []string,
) ([]string, []dnfcompanion.TeamSkip, bool) {
if s == nil || repos.Character == nil || strings.TrimSpace(accountID) == "" ||
!currentDungeonCompanionTeamModeEnabled() {
return configured, nil, false
}
records, err := repos.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
if err != nil {
s.logPacketEvent("dnf-companion-team-match-deferred",
"owner_character_id", ownerCharacterID,
"reason", "character_list_unavailable",
"error", err)
return configured, nil, false
}
ownerLevel := 0
if owner, ownerFound, ownerErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(ownerCharacterID), 10)); ownerErr == nil && ownerFound {
ownerLevel = owner.Level
}
levelFloor := ownerLevel - 10
if levelFloor < 1 {
levelFloor = 1
}
candidates := make([]dnfcompanion.TeamCandidate, 0, len(records))
for _, record := range records {
id := numericCharacterID(record)
if id <= 0 || id >= int(^uint16(0)) {
continue
}
if ownerLevel > 0 && record.Level > 0 && record.Level < levelFloor {
// A level-21 character in a level-90 party's dungeon entry preflight
// makes the whole party fail the level gate. Do not auto-fill with a
// character the dungeon will reject.
s.logPacketEvent("dnf-companion-team-match-level-skipped",
"owner_character_id", ownerCharacterID,
"candidate_character_id", id,
"candidate_level", record.Level,
"owner_level", ownerLevel,
"level_floor", levelFloor)
continue
}
session, online := s.onlineGameSession(uint16(id))
if online && session != nil && session.companionHeadless {
// Our own headless presence is not a competing real player. Without
// this, every later auto-match (preflight/dungeon entry) excludes
// the volunteers it just logged in.
online = false
}
candidates = append(candidates, dnfcompanion.TeamCandidate{
CharacterID: strconv.Itoa(id),
Level: record.Level,
Job: strings.TrimSpace(record.Job),
Slot: record.Slot,
Online: online,
Deleted: record.Stats["delete_flag"] != 0,
})
}
match := dnfcompanion.MatchTeam(
strconv.FormatUint(uint64(ownerCharacterID), 10),
configured,
candidates,
dnfcompanion.TeamMaxCompanions,
)
skipped := make([]string, 0, len(match.Skipped))
for _, entry := range match.Skipped {
if len(skipped) >= 16 {
break
}
skipped = append(skipped, entry.CharacterID+":"+entry.Reason)
}
s.logPacketEvent("dnf-companion-team-match",
"owner_character_id", ownerCharacterID,
"configured", strings.Join(configured, ","),
"selected", strings.Join(match.Selected, ","),
"selected_count", len(match.Selected),
"skipped", strings.Join(skipped, ","),
"limit", dnfcompanion.TeamMaxCompanions)
return match.Selected, match.Skipped, true
}
func currentDungeonCompanionTeamSourceIDs(run *currentDungeonCompanionRun) []uint16 {
if run == nil {
return nil
}
out := make([]uint16, 0, len(run.Sources))
for _, entry := range run.Sources {
if entry.Ready {
out = append(out, entry.SourceCharacterID)
}
}
if len(out) == 0 && run.Ready && run.SourceCharacterID != 0 {
out = append(out, run.SourceCharacterID)
}
return out
}
func currentDungeonCompanionTeamRevision(run *currentDungeonCompanionRun) uint64 {
if run == nil {
return 1
}
if len(run.Body) >= currentDungeonCompanionProjectionHeaderSize {
if revision := binary.LittleEndian.Uint64(run.Body[8:16]); revision != 0 {
return revision
}
}
for _, entry := range run.Sources {
if len(entry.Body) >= currentDungeonCompanionProjectionHeaderSize {
if revision := binary.LittleEndian.Uint64(entry.Body[8:16]); revision != 0 {
return revision
}
}
}
return 1
}
func (s *Service) markCurrentDungeonCompanionTeamOnline(
session *gameSession,
run *currentDungeonCompanionRun,
source string,
) {
if s == nil || run == nil || !run.Ready {
return
}
run.Online = true
run.OnlineAt = s.gameplayNow()
run.OfflineAt = time.Time{}
run.OfflineReason = ""
run.LogoutSent = false
s.logGameEvent(session, "game-dungeon-companion-team-online",
"source", source,
"team_mode", run.TeamMode,
"team_leader_id", run.TeamLeaderID,
"team_size", run.TeamSize,
"ready_count", currentDungeonCompanionReadyCount(run),
"source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run)),
"online_at", run.OnlineAt)
}
// sendCurrentDungeonCompanionTeamOffline publishes the explicit present=0
// projection. The client clears every queued/live support actor on that body,
// so one packet is enough for the whole squad and it is safe to call from every
// dungeon boundary: the run's LogoutSent flag makes it idempotent.
func (s *Service) sendCurrentDungeonCompanionTeamOffline(
session *gameSession,
runtime *runtimeDungeonState,
reason string,
) {
if s == nil || session == nil || runtime == nil {
return
}
run := runtime.Companion
if run == nil || !run.Ready || run.LogoutSent {
return
}
run.LogoutSent = true
run.Online = false
run.OfflineAt = s.gameplayNow()
run.OfflineReason = strings.TrimSpace(reason)
// The dungeon party frame goes away; the synthetic friend sessions do not.
s.clearCurrentDungeonCompanionNativeParty(session, run)
ids := currentDungeonCompanionTeamSourceIDs(run)
body := buildCurrentDungeonCompanionOfflineBody(currentDungeonCompanionTeamRevision(run))
s.logGameEvent(session, "game-dungeon-companion-team-offline",
"reason", run.OfflineReason,
"team_mode", run.TeamMode,
"team_leader_id", run.TeamLeaderID,
"team_size", run.TeamSize,
"offline_count", len(ids),
"source_character_ids", uint16ListLog(ids),
"offline_at", run.OfflineAt,
"private_opcode", currentDungeonCompanionProjectionOpcode,
"body_len", len(body))
if err := s.sendGameUpperRawClass(
session,
currentDungeonCompanionProjectionOpcode,
body,
0,
); err != nil {
s.logGameEvent(session, "game-dungeon-companion-team-offline-send-failed",
"reason", run.OfflineReason,
"error", err)
}
}
func (s *Service) sendCurrentDungeonCompanionTeamOfflineCurrent(
session *gameSession,
reason string,
) {
if s == nil || session == nil {
return
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
session.dungeon.mu.Unlock()
s.sendCurrentDungeonCompanionTeamOffline(session, runtime, reason)
}
func uint16ListLog(values []uint16) string {
if len(values) == 0 {
return ""
}
var builder strings.Builder
for index, value := range values {
if index != 0 {
builder.WriteByte(',')
}
builder.WriteString(strconv.Itoa(int(value)))
}
return builder.String()
}
// runtimeDungeonCompanionCurrent is the compare step used by exit paths that
// opportunistically publish the logout before taking session.dungeon.mu. The
// final authority remains retireCurrentDungeonRuntimeLocked; this only avoids
// sending a stale run's packet when a newer dungeon already replaced it.
func (s *Service) runtimeDungeonCompanionCurrent(
session *gameSession,
runtime *runtimeDungeonState,
) bool {
if s == nil || session == nil || runtime == nil {
return false
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
return session.dungeon.runtime == runtime
}
@@ -0,0 +1,111 @@
package dnfbridge
import (
"bytes"
"encoding/binary"
"net"
"testing"
"time"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
)
// companionOfflineTestConn is intentionally local to this test so the file can
// be compiled without the large shared service_test.go harness.
type companionOfflineTestConn struct {
net.Conn
write bytes.Buffer
}
func (c *companionOfflineTestConn) Write(data []byte) (int, error) { return c.write.Write(data) }
func (c *companionOfflineTestConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestBuildCurrentDungeonCompanionOfflineBodyMatchesPresentZeroContract(t *testing.T) {
body := buildCurrentDungeonCompanionOfflineBody(12345)
if len(body) != currentDungeonCompanionProjectionHeaderSize {
t.Fatalf("body length=%d want=%d", len(body), currentDungeonCompanionProjectionHeaderSize)
}
if got := binary.LittleEndian.Uint32(body[0:4]); got != currentDungeonCompanionProjectionMagic {
t.Fatalf("magic=%#x want=%#x", got, currentDungeonCompanionProjectionMagic)
}
if body[4] != currentDungeonCompanionProjectionVersion {
t.Fatalf("version=%d want=%d", body[4], currentDungeonCompanionProjectionVersion)
}
if body[5] != 0 {
t.Fatalf("flags=%#x want 0 (present must be clear)", body[5])
}
if got := binary.LittleEndian.Uint16(body[6:8]); got != 0 {
t.Fatalf("source_character=%d want 0", got)
}
if got := binary.LittleEndian.Uint64(body[8:16]); got != 12345 {
t.Fatalf("revision=%d want 12345", got)
}
for offset := 16; offset < len(body); offset++ {
if body[offset] != 0 {
t.Fatalf("byte %d=%#x want 0", offset, body[offset])
}
}
}
func TestSendCurrentDungeonCompanionTeamOfflineIsIdempotent(t *testing.T) {
connection := &companionOfflineTestConn{}
session := &gameSession{conn: connection, connID: "companion-team-logout"}
run := &currentDungeonCompanionRun{
Ready: true,
TeamMode: true,
TeamLeaderID: 1,
TeamSize: 2,
Online: true,
Sources: []currentDungeonCompanionSourceRun{
{Ready: true, SourceCharacterID: 2, Body: buildCurrentDungeonCompanionOfflineBody(77)},
{Ready: true, SourceCharacterID: 3, Body: buildCurrentDungeonCompanionOfflineBody(77)},
},
}
runtime := &runtimeDungeonState{Companion: run}
service := &Service{}
service.sendCurrentDungeonCompanionTeamOffline(session, runtime, "dungeon_return_town")
if !run.LogoutSent || run.Online {
t.Fatalf("run state after logout = sent:%v online:%v", run.LogoutSent, run.Online)
}
if run.OfflineAt.IsZero() || run.OfflineReason != "dungeon_return_town" {
t.Fatalf("offline evidence = at:%v reason:%q", run.OfflineAt, run.OfflineReason)
}
first := connection.write.Len()
if first == 0 {
t.Fatal("logout packet was not sent")
}
// A second boundary must not replay the packet.
service.sendCurrentDungeonCompanionTeamOffline(session, runtime, "return_select_character")
if connection.write.Len() != first {
t.Fatalf("second logout wrote %d extra bytes", connection.write.Len()-first)
}
stream := connection.write.Bytes()
headerSize := service.gameUpperHeaderSize()
if len(stream) < headerSize {
t.Fatalf("logout stream too short: %d", len(stream))
}
total := int(binary.LittleEndian.Uint32(stream[3:7]))
if total != len(stream) {
t.Fatalf("logout packet total=%d stream=%d", total, len(stream))
}
if msgID := binary.LittleEndian.Uint16(stream[1:3]); msgID != currentDungeonCompanionProjectionOpcode {
t.Fatalf("msg id=%#x want=%#x", msgID, currentDungeonCompanionProjectionOpcode)
}
body := stream[headerSize:total]
if len(body) != currentDungeonCompanionProjectionHeaderSize || body[5] != 0 {
t.Fatalf("logout body length=%d flags=%d", len(body), body[5])
}
}
func TestMatchTeamAutoFillKeepsProjectionPipelinePerSource(t *testing.T) {
// The matcher is pure; this guard pins the cap the bridge passes so a future
// change cannot silently form a five-player dungeon party.
if dnfcompanion.TeamMaxCompanions != 3 {
t.Fatalf("TeamMaxCompanions=%d want 3", dnfcompanion.TeamMaxCompanions)
}
}
@@ -45,6 +45,20 @@ type gameSession struct {
completedAutoNextRuntime *runtimeDungeonState
conn net.Conn
// companionVirtual marks a synthetic session that exists only so the native
// friend list and party frame can resolve an offline volunteer as an online
// character. It has no socket and must never receive a normal send path.
companionVirtual bool
// companionHeadless is a real session in gameSessions with a sink transport.
// Party/dungeon owners treat it as a real member; no client is attached.
companionHeadless bool
companionOwnerID uint16
// companionForceEnter marks the admin-driven op16 so dungeon entry can log
// its exact refusal reason to the plaintext headless debug file.
companionForceEnter bool
// headlessActorPublished marks that this headless member's own actor rows
// were projected to the leader after the dungeon room committed.
headlessActorPublished bool
// writer owns this session's blocking socket writes so the event loop never
// parks on a client that stopped reading. See game_session_writer.go for the
// live 48-second stall measurement that made it necessary. nil means "no
@@ -13,6 +13,10 @@ type boundGameSessionCharacter struct {
session *gameSession
character uint16
generation uint64
// virtual marks a companion presence session. Such a session is not in
// Service.gameSessions (it has no socket), so the liveness check below must
// consult Service.companionSessions instead.
virtual bool
}
func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16) {
@@ -53,6 +57,13 @@ func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16)
s.stopCurrentSpendTimeClock(session, "selected_character_changed")
s.stopCurrentPetGrowthClock(session, "selected_character_changed")
}
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
session.dungeon.mu.Lock()
pendingCompanionRuntime := session.dungeon.runtime
session.dungeon.mu.Unlock()
s.sendCurrentDungeonCompanionTeamOffline(
session, pendingCompanionRuntime, "selected_character_changed")
}
session.dungeon.mu.Lock()
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
runtime := session.dungeon.runtime
@@ -143,6 +154,9 @@ func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16)
session.collectboxSelectionRefreshSent = false
}
session.selectedCharacterID = charID
// A real client binding this character always retires the synthetic
// volunteer presence row, so the friend list cannot claim two online copies.
s.dropCompanionPresenceCharacter(charID)
if resident != nil && resident != session {
if current := s.gameSessions[charID]; current == resident {
s.mu.Unlock()
@@ -436,6 +450,7 @@ func (s *Service) releaseDisconnectedDungeonAttempt(session *gameSession, reason
// 掉线不退队 —— 座位由 raid_disconnect.go 的宽限窗口守着,这里存的坐标只作
// 诊断与重连参考(重连实际一律回 PVF 等待室起点,见 runtimeFiendWarRejoinLocation)。
s.captureRuntimeFiendWarRejoinPosition(session)
s.sendCurrentDungeonCompanionTeamOfflineCurrent(session, "disconnected")
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime == nil {
@@ -538,6 +553,9 @@ func (s *Service) boundGameSessionCharacterCurrent(identity boundGameSessionChar
if s == nil || identity.session == nil || identity.character == 0 {
return false
}
if identity.virtual {
return s.companionSessionCurrent(identity)
}
s.mu.Lock()
defer s.mu.Unlock()
return s.gameSessions[identity.character] == identity.session &&
@@ -73,6 +73,7 @@ func (s *Service) commitCurrentAvatarSocketOpen(session *gameSession, request cu
if err != nil {
return currentSocketMutationResult{}, err
}
socketTypes = currentAvatarSocketTypesForOpen(rule, socketTypes)
if len(socketTypes) == 0 {
return currentSocketMutationResult{}, fmt.Errorf("%w: avatar socket definitions missing item=%d", errCurrentSocketPVFInvalid, request.TargetItemID)
}
@@ -0,0 +1,434 @@
package dnfbridge
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"time"
dnfauction "longheng.io/server/internal/modules/dnf/auction"
)
type currentServerFeatureSettings struct {
RaidReviveSeconds int `json:"raidReviveSeconds"`
AllDeadReturnSeconds int `json:"allDeadReturnSeconds"`
AllowFreeRevive *bool `json:"allowFreeRevive"`
HellOrbChance int `json:"hellOrbChance"`
HellDropChanceDenominator int `json:"hellDropChanceDenominator"`
EquipmentRollMultiplier int `json:"equipmentRollMultiplier"`
EpicPieceRollMultiplier int `json:"epicPieceRollMultiplier"`
AntonRaidAttackSeconds int `json:"antonRaidAttackSeconds"`
LukeEnergyRecoveryPercent int `json:"lukeEnergyRecoveryPercent"`
FiendWarSquadCount int `json:"fiendWarSquadCount"`
RoomFatigueCost int `json:"roomFatigueCost"`
DailyFatigueResetHour int `json:"dailyFatigueResetHour"`
AdventureGroupBonusPercent int `json:"adventureGroupBonusPercent"`
RentalMaxReinforce int `json:"rentalMaxReinforce"`
RentalMaxForge int `json:"rentalMaxForge"`
ReviveByDungeon map[string]int `json:"reviveByDungeon"`
AntonBlackFogRecoverySeconds int `json:"antonBlackFogRecoverySeconds"`
AntonBlackFogActiveSeconds int `json:"antonBlackFogActiveSeconds"`
AntonQuakeRecoverySeconds int `json:"antonQuakeRecoverySeconds"`
AntonQuakeActiveSeconds int `json:"antonQuakeActiveSeconds"`
AntonNavalOpenDelaySeconds int `json:"antonNavalOpenDelaySeconds"`
AntonNavalRecoverySeconds int `json:"antonNavalRecoverySeconds"`
AntonNavalActiveSeconds int `json:"antonNavalActiveSeconds"`
LukeEnergyRecovery0Seconds int `json:"lukeEnergyRecovery0Seconds"`
LukeEnergyRecovery1Seconds int `json:"lukeEnergyRecovery1Seconds"`
LukeEnergyRecovery2Seconds int `json:"lukeEnergyRecovery2Seconds"`
LukePhaseTimeoutPercent int `json:"lukePhaseTimeoutPercent"`
FiendWarAreaLimitPercent int `json:"fiendWarAreaLimitPercent"`
FiendWarDieImprisonPercent int `json:"fiendWarDieImprisonPercent"`
SoloTeleportCooldownSeconds int `json:"soloTeleportCooldownSeconds"`
SoloTeleportCooldownIndices string `json:"soloTeleportCooldownIndices"`
AuctionStandardListingLimit int `json:"auctionStandardListingLimit"`
AuctionPremiumListingLimit int `json:"auctionPremiumListingLimit"`
AuctionStandardDurationHours int `json:"auctionStandardDurationHours"`
AuctionPremiumDurationHours int `json:"auctionPremiumDurationHours"`
AntonMemberMax int `json:"antonMemberMax"`
AntonStartMinimumMembers int `json:"antonStartMinimumMembers"`
AntonSingleStartMinimumMembers int `json:"antonSingleStartMinimumMembers"`
AntonStartDelaySeconds int `json:"antonStartDelaySeconds"`
AntonPhaseBreakSeconds int `json:"antonPhaseBreakSeconds"`
AntonEntryCostItem int `json:"antonEntryCostItem"`
AntonEntryCostCount int `json:"antonEntryCostCount"`
AntonWeeklyEnterCountA int `json:"antonWeeklyEnterCountA"`
AntonWeeklyEnterCountB int `json:"antonWeeklyEnterCountB"`
AntonPhaseTimeoutSeconds string `json:"antonPhaseTimeoutSeconds"`
LukeDailyAccountEnterLimit int `json:"lukeDailyAccountEnterLimit"`
LukeSanctumTimerCount int `json:"lukeSanctumTimerCount"`
LukeEntryCostItem int `json:"lukeEntryCostItem"`
LukeEntryCostCount int `json:"lukeEntryCostCount"`
FiendWarMemberMax int `json:"fiendWarMemberMax"`
FiendWarStartMinimumMembers int `json:"fiendWarStartMinimumMembers"`
FiendWarDisconnectWaitSeconds int `json:"fiendWarDisconnectWaitSeconds"`
FiendWarEntryCostItem int `json:"fiendWarEntryCostItem"`
FiendWarEntryCostCount int `json:"fiendWarEntryCostCount"`
FiendWarRetry *bool `json:"fiendWarRetry"`
FiendWarDailyEnterLimit int `json:"fiendWarDailyEnterLimit"`
AuctionStandardFeePercent int `json:"auctionStandardFeePercent"`
AuctionPremiumFeePercent int `json:"auctionPremiumFeePercent"`
}
func GMSaveServerFeatures(data []byte) error {
var values map[string]json.RawMessage
if err := json.Unmarshal(data, &values); err != nil {
return err
}
path := currentServerConfigPath("server-features.json")
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func currentServerConfigPath(name string) string {
candidates := []string{filepath.Join("config", name)}
if executable, err := os.Executable(); err == nil {
candidates = append(candidates,
filepath.Join(filepath.Dir(executable), "..", "config", name),
filepath.Join(filepath.Dir(executable), "config", name),
)
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return candidates[0]
}
func currentServerFeatures() currentServerFeatureSettings {
data, err := os.ReadFile(currentServerConfigPath("server-features.json"))
if err != nil {
return currentServerFeatureSettings{}
}
var settings currentServerFeatureSettings
if err := json.Unmarshal(data, &settings); err != nil {
return currentServerFeatureSettings{}
}
return settings
}
func currentActivityEnabled(key string) bool {
data, err := os.ReadFile(currentServerConfigPath("activity.json"))
if err != nil {
return true
}
var values map[string]bool
if err := json.Unmarshal(data, &values); err != nil {
return true
}
enabled, found := values[key]
if !found {
return true
}
return enabled
}
func currentConfiguredAllDeadReturnDelay() time.Duration {
const fallback = currentPartyDungeonAllDeadReturnDelay
settings := currentServerFeatures()
if settings.AllDeadReturnSeconds < 1 || settings.AllDeadReturnSeconds > 60 {
return fallback
}
return time.Duration(settings.AllDeadReturnSeconds) * time.Second
}
func currentConfiguredAllowFreeRevive() bool {
settings := currentServerFeatures()
if settings.AllowFreeRevive == nil {
return currentDungeonUseCoinAllowFreeRevive
}
return *settings.AllowFreeRevive
}
func currentConfiguredHellOrbChance() int {
settings := currentServerFeatures()
if settings.HellOrbChance < 0 || settings.HellOrbChance > currentDungeonHellOrbRollDenominator {
return currentDungeonHellOrbChance
}
return settings.HellOrbChance
}
func currentConfiguredHellDropChanceDenominator() int {
settings := currentServerFeatures()
if settings.HellDropChanceDenominator < 1 || settings.HellDropChanceDenominator > 1_000_000 {
return currentHellPartyDropChanceDenominator
}
return settings.HellDropChanceDenominator
}
func currentConfiguredAntonRaidAttackSeconds() int {
settings := currentServerFeatures()
if settings.AntonRaidAttackSeconds < 60 || settings.AntonRaidAttackSeconds > 24*60*60 {
return antonRaidAttackSeconds
}
return settings.AntonRaidAttackSeconds
}
func currentConfiguredLukeEnergyRecoveryPercent() int {
settings := currentServerFeatures()
if settings.LukeEnergyRecoveryPercent < 1 || settings.LukeEnergyRecoveryPercent > 1000 {
return 100
}
return settings.LukeEnergyRecoveryPercent
}
func currentConfiguredFiendWarSquadCount() int {
settings := currentServerFeatures()
if settings.FiendWarSquadCount < 1 || settings.FiendWarSquadCount > 8 {
return currentFiendWarSquadCount
}
return settings.FiendWarSquadCount
}
func currentConfiguredRoomFatigueCost() int64 {
settings := currentServerFeatures()
if settings.RoomFatigueCost < 0 || settings.RoomFatigueCost > 100 {
return currentDungeonRoomFatigueCost
}
return int64(settings.RoomFatigueCost)
}
func currentConfiguredDailyFatigueResetHour() int {
settings := currentServerFeatures()
if settings.DailyFatigueResetHour < 0 || settings.DailyFatigueResetHour > 23 {
return 6
}
return settings.DailyFatigueResetHour
}
func currentConfiguredAdventureGroupBonusPercent() int {
settings := currentServerFeatures()
if settings.AdventureGroupBonusPercent < 0 || settings.AdventureGroupBonusPercent > 1000 {
return currentDungeonAdventureGroupDefaultClearBonusPercent
}
return settings.AdventureGroupBonusPercent
}
func currentConfiguredRentalMaxReinforce() int {
settings := currentServerFeatures()
if settings.RentalMaxReinforce < 0 || settings.RentalMaxReinforce > 31 {
return currentRentalMaxReinforce
}
return settings.RentalMaxReinforce
}
func currentConfiguredRentalMaxForge() int {
settings := currentServerFeatures()
if settings.RentalMaxForge < 0 || settings.RentalMaxForge > 255 {
return currentRentalMaxForge
}
return settings.RentalMaxForge
}
func currentConfiguredDungeonReviveSeconds(dungeonID int64) (int, bool) {
if dungeonID <= 0 {
return 0, false
}
settings := currentServerFeatures()
if settings.ReviveByDungeon == nil {
return 0, false
}
value, found := settings.ReviveByDungeon[strconv.FormatInt(dungeonID, 10)]
if !found || value < 1 || value > 86400 {
return 0, false
}
return value, true
}
func currentConfiguredDungeonReviveDelay(dungeonID int64) time.Duration {
seconds, found := currentConfiguredDungeonReviveSeconds(dungeonID)
if !found {
return currentConfiguredRaidReviveDelay()
}
return time.Duration(seconds) * time.Second
}
func currentAntonDuration(configured int, fallback time.Duration) time.Duration {
if configured < 1 || configured > 86400 {
return fallback
}
return time.Duration(configured) * time.Second
}
func currentAntonBlackFogRecovery() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonBlackFogRecoverySeconds, antonBlackFogRecovery)
}
func currentAntonBlackFogActive() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonBlackFogActiveSeconds, antonBlackFogActive)
}
func currentAntonQuakeRecovery() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonQuakeRecoverySeconds, antonQuakeRecovery)
}
func currentAntonQuakeActive() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonQuakeActiveSeconds, antonQuakeActive)
}
func currentAntonNavalOpenDelay() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonNavalOpenDelaySeconds, antonNavalOpenDelay)
}
func currentAntonNavalRecovery() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonNavalRecoverySeconds, antonNavalRecovery)
}
func currentAntonNavalActive() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonNavalActiveSeconds, antonNavalActive)
}
func currentLukeEnergyRecovery(tier int) time.Duration {
if tier < 0 {
tier = 0
}
if tier >= len(lukeEnergyControlRecovery) {
tier = len(lukeEnergyControlRecovery) - 1
}
settings := currentServerFeatures()
values := [...]int{
settings.LukeEnergyRecovery0Seconds,
settings.LukeEnergyRecovery1Seconds,
settings.LukeEnergyRecovery2Seconds,
}
if values[tier] >= 1 && values[tier] <= 86400 {
return time.Duration(values[tier]) * time.Second
}
percent := settings.LukeEnergyRecoveryPercent
if percent < 1 || percent > 1000 {
percent = 100
}
return time.Duration(int64(lukeEnergyControlRecovery[tier]) * int64(percent) / 100)
}
func currentLukePhaseTimeoutPercent() int {
percent := currentServerFeatures().LukePhaseTimeoutPercent
if percent < 1 || percent > 1000 {
return 100
}
return percent
}
func currentFiendWarAreaLimitPercent() int {
percent := currentServerFeatures().FiendWarAreaLimitPercent
if percent < 1 || percent > 1000 {
return 100
}
return percent
}
func currentFiendWarDieImprisonPercent() int {
percent := currentServerFeatures().FiendWarDieImprisonPercent
if percent < 1 || percent > 1000 {
return 100
}
return percent
}
func currentConfiguredSoloTeleportCooldownSeconds() uint32 {
seconds := currentServerFeatures().SoloTeleportCooldownSeconds
if seconds < 1 || seconds > 86400 {
return currentSoloTeleportCooldownSeconds
}
return uint32(seconds)
}
func currentConfiguredSoloTeleportIndices() []byte {
raw := strings.TrimSpace(currentServerFeatures().SoloTeleportCooldownIndices)
if raw == "" {
return nil
}
if strings.EqualFold(raw, "off") || strings.EqualFold(raw, "none") {
return []byte{}
}
var indices []byte
for _, field := range strings.Split(raw, ",") {
value, err := strconv.ParseUint(strings.TrimSpace(field), 10, 8)
if err == nil && currentSoloTeleportCooldownIndexAccepted(byte(value)) {
indices = append(indices, byte(value))
}
}
return indices
}
func currentConfiguredAuctionListingLimit(premium bool) int {
settings := currentServerFeatures()
value := settings.AuctionStandardListingLimit
if premium {
value = settings.AuctionPremiumListingLimit
}
if value < 1 || value > 1000 {
if premium {
return currentAuctionPremiumListingLimit
}
return currentAuctionStandardListingLimit
}
return value
}
func currentConfiguredAuctionDuration(premium bool) time.Duration {
settings := currentServerFeatures()
hours := settings.AuctionStandardDurationHours
if premium {
hours = settings.AuctionPremiumDurationHours
}
if hours < 1 || hours > 720 {
if premium {
return currentAuctionPremiumDuration
}
return currentAuctionStandardDuration
}
return time.Duration(hours) * time.Hour
}
func currentConfiguredAuctionFeePercent(premium bool) int64 {
settings := currentServerFeatures()
value := settings.AuctionStandardFeePercent
if premium {
value = settings.AuctionPremiumFeePercent
}
if value <= 0 || value > 100 {
return dnfauction.SaleFeePercentFor(premium)
}
return int64(value)
}
// currentConfiguredRaidReviveDelay reads the optional runtime/config/
// server-features.json written by the standalone server tool. It only changes
// the server-side death watchdog; the current EXE still owns its ten-second
// revive prompt.
func currentConfiguredRaidReviveDelay() time.Duration {
const fallback = currentDungeonDeathReturnDelay
settings := currentServerFeatures()
if settings.RaidReviveSeconds < 1 || settings.RaidReviveSeconds > 60 {
return fallback
}
return time.Duration(settings.RaidReviveSeconds) * time.Second
}
// currentConfiguredEquipmentRollMultiplier keeps the local test-end special
// drop tuning (2x equipment rolls) configurable by the standalone server tool.
func currentConfiguredEquipmentRollMultiplier() int {
settings := currentServerFeatures()
if settings.EquipmentRollMultiplier < 1 || settings.EquipmentRollMultiplier > 100 {
return 1
}
return settings.EquipmentRollMultiplier
}
// currentConfiguredEpicPieceRollMultiplier keeps the local test-end special
// drop tuning (3x epic fragment rolls) configurable by the standalone tool.
func currentConfiguredEpicPieceRollMultiplier() int {
settings := currentServerFeatures()
if settings.EpicPieceRollMultiplier < 1 || settings.EpicPieceRollMultiplier > 100 {
return 1
}
return settings.EpicPieceRollMultiplier
}
@@ -85,6 +85,12 @@ type Service struct {
// 配置按账号落库(repos.Settings),运行态按副本冻结;这把锁只保护那一段
// "读当前 revision → 校验 → 写回"的临界区,避免并发保存把 revision 撞掉。
companionConfigMu sync.Mutex
// companionPresenceMu guards the synthetic sessions that expose a volunteer
// in the native friend list / party frame. These sessions have no socket and
// are deliberately NOT stored in gameSessions: only the social and party
// projection helpers below may resolve them.
companionPresenceMu sync.Mutex
companionSessions map[uint16]*gameSession
mu sync.Mutex
catalog *channelcatalog.Catalog
@@ -69,6 +69,21 @@ func currentSocketAvatarSocketTypes(catalog *pvfDungeonDropCatalog, itemID int64
return socketTypes, nil
}
// currentAvatarSocketTypesForOpen applies the avatar-part socket layout that
// the PVF's [avatar type select] does not encode explicitly. Rare coat/pants
// avatars carry one platinum (0x10) socket followed by two normal C sockets;
// their PVF section only lists the two C tags, so the opener used to produce a
// two-socket result. Keep other avatar parts and explicit three-socket layouts
// untouched.
func currentAvatarSocketTypesForOpen(rule currentEquipmentPlacementRule, socketTypes []byte) []byte {
out := append([]byte(nil), socketTypes...)
if (rule.targetSlot == 3 || rule.targetSlot == 4) &&
len(out) == 2 && out[0] == 0x04 && out[1] == 0x04 {
return []byte{0x10, 0x04, 0x04}
}
return out
}
func currentSocketEmblemType(catalog *pvfDungeonDropCatalog, itemID int64) byte {
if catalog == nil || itemID <= 0 || itemID > math.MaxUint32 {
return 0