合并本地领先源码:旧UI、GM疲劳燃烧、白金徽章孔、宠物修复、支援兵普攻开关
This commit is contained in:
1 parent
f2debaf9bf
commit
70b3c5a6dc
57 files changed
+4256
-30
No files matched your search
@@ -0,0 +1,277 @@
|
||||
// Command companionoverlay — 支援兵覆盖工具(单文件版)。
|
||||
//
|
||||
// 把「支援兵 AIC 覆盖」做成一个可独立发布的可执行程序:内置 companionpvf 与
|
||||
// profiles.json,用户只需拖入 Script.pvf(或直接双击用默认路径),即可:
|
||||
//
|
||||
// 1) 生成覆盖版 PVF(默认输出到同目录 Script.companion.pvf);
|
||||
// 2) 可选地安装到客户端/服务端目录(自动备份、打印哈希)。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// 支援兵覆盖工具.exe 交互模式(使用默认客户端路径)
|
||||
// 支援兵覆盖工具.exe <Script.pvf> 拖拽/命令行:只生成覆盖版
|
||||
// 支援兵覆盖工具.exe <Script.pvf> --client <客户端目录> --server <服务端数据目录>
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed companionpvf.exe profiles.json
|
||||
var assets embed.FS
|
||||
|
||||
const (
|
||||
appTitle = "DNF90 支援兵覆盖工具"
|
||||
appVersion = "1.0.0"
|
||||
defaultClient = `D:\90server\Client\Script.pvf`
|
||||
minOutputBytes = 50 << 20
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("%s v%s\n", appTitle, appVersion)
|
||||
fmt.Println(strings.Repeat("=", 60))
|
||||
|
||||
args := os.Args[1:]
|
||||
pvfPath, clientDir, serverDir, install, interactive := parseArgs(args)
|
||||
|
||||
if interactive {
|
||||
pvfPath = prompt("要处理的 Script.pvf 路径", pvfPath)
|
||||
if !ask("是否同时安装到客户端/服务端目录?(y/N): ") {
|
||||
install = false
|
||||
} else {
|
||||
install = true
|
||||
clientDir = prompt("客户端目录(含 Script.pvf)", clientDir)
|
||||
serverDir = prompt("服务端数据目录(含 Script.pvf)", serverDir)
|
||||
}
|
||||
}
|
||||
if !install {
|
||||
clientDir, serverDir = "", ""
|
||||
}
|
||||
|
||||
if strings.TrimSpace(pvfPath) == "" {
|
||||
fail("没有指定 Script.pvf")
|
||||
}
|
||||
source, err := filepath.Abs(pvfPath)
|
||||
if err != nil || !exists(source) {
|
||||
fail("找不到 PVF:" + pvfPath)
|
||||
}
|
||||
|
||||
outPath := strings.TrimSuffix(source, filepath.Ext(source)) + ".companion.pvf"
|
||||
if dir := filepath.Dir(source); dir != "" {
|
||||
outPath = filepath.Join(dir, "Script.companion.pvf")
|
||||
}
|
||||
|
||||
fmt.Printf("输入 PVF:%s\n", source)
|
||||
fmt.Printf("输出 PVF:%s\n", outPath)
|
||||
|
||||
if err := runOverlay(source, outPath); err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
size := fileSize(outPath)
|
||||
fmt.Printf("覆盖版已生成:%d 字节(%.1f MB)\n", size, float64(size)/(1<<20))
|
||||
if size < minOutputBytes {
|
||||
fail("输出文件过小,疑似失败(原 PVF 未被修改)")
|
||||
}
|
||||
hash := fileHash(outPath)
|
||||
fmt.Printf("覆盖版 SHA256:%s\n", hash)
|
||||
|
||||
if install {
|
||||
if err := installTo(source, outPath, hash, clientDir, serverDir); err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
} else {
|
||||
fmt.Println()
|
||||
fmt.Println("未安装。手动替换方法:")
|
||||
fmt.Println(" 客户端:把输出文件改名为 Script.pvf 覆盖到 客户端目录。")
|
||||
fmt.Println(" 服务端:同样覆盖到 服务端 runtime\\data\\dnf\\Script.pvf。")
|
||||
fmt.Println(" 完成后重启服务端,再启动游戏。")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("完成。")
|
||||
if interactive {
|
||||
fmt.Print("按回车退出...")
|
||||
bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
}
|
||||
}
|
||||
|
||||
func parseArgs(args []string) (pvf, clientDir, serverDir string, install, interactive bool) {
|
||||
clientDir = `D:\90server\Client`
|
||||
serverDir = `D:\90server\Server\runtime\data\dnf`
|
||||
pvf = defaultClient
|
||||
interactive = len(args) == 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--client":
|
||||
if i+1 < len(args) {
|
||||
clientDir = args[i+1]
|
||||
install = true
|
||||
i++
|
||||
}
|
||||
case "--server":
|
||||
if i+1 < len(args) {
|
||||
serverDir = args[i+1]
|
||||
install = true
|
||||
i++
|
||||
}
|
||||
case "--install":
|
||||
install = true
|
||||
default:
|
||||
if !strings.HasPrefix(args[i], "--") {
|
||||
pvf = args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return pvf, clientDir, serverDir, install, interactive
|
||||
}
|
||||
|
||||
func runOverlay(source, outPath string) error {
|
||||
toolPath := filepath.Join(os.TempDir(), fmt.Sprintf("companionpvf-%d.exe", time.Now().UnixNano()))
|
||||
toolBytes, err := assets.ReadFile("companionpvf.exe")
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取内置工具失败:%w", err)
|
||||
}
|
||||
if err := os.WriteFile(toolPath, toolBytes, 0o700); err != nil {
|
||||
return fmt.Errorf("释放内置工具失败:%w", err)
|
||||
}
|
||||
defer os.Remove(toolPath)
|
||||
|
||||
profilesPath := filepath.Join(os.TempDir(), fmt.Sprintf("companion-profiles-%d.json", time.Now().UnixNano()))
|
||||
profilesBytes, err := assets.ReadFile("profiles.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取内置配置失败:%w", err)
|
||||
}
|
||||
if err := os.WriteFile(profilesPath, profilesBytes, 0o600); err != nil {
|
||||
return fmt.Errorf("释放内置配置失败:%w", err)
|
||||
}
|
||||
defer os.Remove(profilesPath)
|
||||
|
||||
fmt.Println("生成覆盖版(约需 10–60 秒)...")
|
||||
cmd := exec.Command(toolPath, "--profile-overlay", source, outPath, profilesPath)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("覆盖失败:%w(原 PVF 未被修改)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installTo(source, outPath, hash, clientDir, serverDir string) error {
|
||||
if hasProcess("DNF") {
|
||||
return fmt.Errorf("请先关闭 DNF 客户端:PVF 正被占用")
|
||||
}
|
||||
targets := []string{
|
||||
filepath.Join(clientDir, "Script.pvf"),
|
||||
filepath.Join(serverDir, "Script.pvf"),
|
||||
}
|
||||
stamp := time.Now().Format("20060102-150405")
|
||||
for _, target := range targets {
|
||||
if !exists(target) {
|
||||
fmt.Printf("跳过(不存在):%s\n", target)
|
||||
continue
|
||||
}
|
||||
if fileHash(target) == hash {
|
||||
fmt.Printf("已是最新,跳过:%s\n", target)
|
||||
continue
|
||||
}
|
||||
backup := target + ".bak_before_companion_" + stamp
|
||||
if err := copyFile(target, backup); err != nil {
|
||||
return fmt.Errorf("备份失败:%w", err)
|
||||
}
|
||||
fmt.Printf("已备份:%s\n", backup)
|
||||
if err := copyFile(outPath, target); err != nil {
|
||||
return fmt.Errorf("安装失败:%w", err)
|
||||
}
|
||||
fmt.Printf("已安装:%s\n", target)
|
||||
}
|
||||
fmt.Println("请重启 DNF90 服务端(服务端启动时加载 PVF),再启动游戏。")
|
||||
return nil
|
||||
}
|
||||
|
||||
func prompt(label, fallback string) string {
|
||||
fmt.Printf("%s [%s]: ", label, fallback)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
text, _ := reader.ReadString('\n')
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.Trim(text, `"`)
|
||||
}
|
||||
|
||||
func ask(label string) bool {
|
||||
fmt.Print(label)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
text, _ := reader.ReadString('\n')
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
return text == "y" || text == "yes"
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func fileSize(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func fileHash(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(h.Sum(nil)))
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func hasProcess(name string) bool {
|
||||
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name+".exe", "/NH")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(output)), strings.ToLower(name+".exe"))
|
||||
}
|
||||
|
||||
func fail(message string) {
|
||||
fmt.Fprintf(os.Stderr, "\n[错误] %s\n", message)
|
||||
fmt.Print("按回车退出...")
|
||||
bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -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 := ¤tDungeonCompanionRun{
|
||||
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 := ¤tDungeonCompanionRun{
|
||||
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 := ¤tDungeonCompanionRun{
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user