Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

202 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// cutsceneTriggerFileName 是客户端补丁轮询的触发文件。
//
// 机制(已实测可用,见 client-patch-src/CUTSCENE-FORCE-NOTES.md):
// 客户端补丁每 250ms 检查客户端目录下的这个文件,读到数字 N 就调用客户端自己的
// 影片播放器播放 `Video/movie/origin_cinematic_NN.avi`,然后删除文件。
//
// 服务端与客户端同机,所以开区任务完成时只要写这个文件,就能让客户端**当场**
// 播放该区域的章节过场 —— 不依赖 op1378 的播放判定(那条路径只在角色选择时生效,
// 城镇场景重建时客户端会直接忽略)。
const cutsceneTriggerFileName = "90CN_play_cutscene.txt"
// resolveClientCutsceneTriggerPath 解析触发文件路径。
//
// 优先 DNF90_CLIENT_DIR 环境变量;否则从服务端工作目录(runtime)上溯两级找 Client:
//
// <root>\Server\runtime -> <root>\Client
func resolveClientCutsceneTriggerPath() (string, error) {
if dir := strings.TrimSpace(os.Getenv("DNF90_CLIENT_DIR")); dir != "" {
return filepath.Join(dir, cutsceneTriggerFileName), nil
}
working, err := os.Getwd()
if err != nil {
return "", err
}
candidates := []string{
filepath.Join(working, "..", "..", "Client"),
filepath.Join(working, "..", "Client"),
filepath.Join(working, "Client"),
}
for _, dir := range candidates {
if info, statErr := os.Stat(dir); statErr == nil && info.IsDir() {
return filepath.Join(dir, cutsceneTriggerFileName), nil
}
}
return "", fmt.Errorf("client directory not found from %s (set DNF90_CLIENT_DIR)", working)
}
// triggerClientCutsceneFile 写触发文件,让客户端播放 sequence 对应的章节影片。
func triggerClientCutsceneFile(sequence int) error {
if sequence <= 0 || sequence > 99 {
return fmt.Errorf("invalid cutscene sequence %d", sequence)
}
path, err := resolveClientCutsceneTriggerPath()
if err != nil {
return err
}
return os.WriteFile(path, []byte(strconv.Itoa(sequence)+"\n"), 0o644)
}
// triggerClientCutsceneFileDelayed 延迟写入触发文件。
//
// 开区任务完成的那一瞬,客户端正在重建城镇场景(op22→op23→op356→同区域 op24);
// 此时立刻播放影片,窗口会被随后的场景重建拆掉(实测:影片确实被打开,但没显示)。
// 等重建稳定后再触发即可正常播放。
func triggerClientCutsceneFileDelayed(sequence int) error {
if sequence <= 0 || sequence > 99 {
return fmt.Errorf("invalid cutscene sequence %d", sequence)
}
go func(value int) {
time.Sleep(2 * time.Second)
_ = triggerClientCutsceneFile(value)
}(sequence)
return nil
}
// ── 场景就绪后触发 ─────────────────────────────────────────────────────
//
// 开区任务完成的那一刻客户端正在重建城镇场景;立刻播放影片会被随后的重建拆掉。
// 经验证,可靠的就绪信号是「客户端又发来任意一个包」—— 说明它已经处理完场景
// 重建并在正常交互。因此这里记下"待播影片",等下一个入站包到达时触发。
//
// 但玩家可能停在任务结算/对话界面里一直不操作(实测有 7 秒没有发出任何包的
// 情况),只等入站包会让动画迟迟不出现。所以再加一个 cutsceneArmTimeout 兜底:
// 到达上限后无论有没有收到包都触发。两条路径共用同一个待播表,先到先得、不会重播。
type cutsceneArmEntry struct {
sequence int
notBefore time.Time
}
var cutsceneArmed = struct {
sync.Mutex
m map[*gameSession]cutsceneArmEntry
}{m: make(map[*gameSession]cutsceneArmEntry)}
// cutsceneArmTimeout 是"最多等多久"的兜底上限(从 arm 起算)。
const cutsceneArmTimeout = 2 * time.Second
// armCutsceneAfterSceneReady 记下待播影片:客户端下一个包到达时触发,
// 最多等 cutsceneArmTimeout。
func (s *Service) armCutsceneAfterSceneReady(session *gameSession, sequence int) {
if session == nil || sequence <= 0 || sequence > 99 {
return
}
cutsceneArmed.Lock()
cutsceneArmed.m[session] = cutsceneArmEntry{
sequence: sequence,
notBefore: time.Now().Add(1500 * time.Millisecond),
}
cutsceneArmed.Unlock()
// 兜底定时器:玩家一直不操作时也要把动画播出来。
go func() {
time.Sleep(cutsceneArmTimeout)
s.fireReadyCutscene(session, "scene_ready_timeout")
}()
}
// takeReadyCutscene 在入站包到达时调用:满足时间条件就取走并返回待播影片号。
func takeReadyCutscene(session *gameSession) (int, bool) {
if session == nil {
return 0, false
}
now := time.Now()
cutsceneArmed.Lock()
entry, ok := cutsceneArmed.m[session]
if ok && now.After(entry.notBefore) {
delete(cutsceneArmed.m, session)
} else {
ok = false
}
cutsceneArmed.Unlock()
return entry.sequence, ok
}
// fireReadyCutscene 取走待播影片并写触发文件,然后把握高的 last_level 拉回当前
// 等级(否则下次进副本会被判定为段差而补播)。入站包路径和超时兜底共用本函数;
// 待播表是"取走即删除",所以两条路径不会重复播放。
func (s *Service) fireReadyCutscene(session *gameSession, source string) {
sequence, ready := takeReadyCutscene(session)
if !ready {
return
}
if err := triggerClientCutsceneFile(sequence); err != nil {
s.logGameEvent(session, "game-upper-finish-quest-cutscene-file-trigger-failed",
"sequence", sequence, "source", source, "error", err)
return
}
s.logGameEvent(session, "game-upper-finish-quest-cutscene-file-triggered",
"sequence", sequence, "source", source)
// 开区路径为了触发影片故意让 last_level 落后一段;场景已经就绪后
// 必须把它拉回当前等级,否则下一次进副本又会被判定为段差并补播。
s.syncCurrentStoryDigestToCurrentLevel(session, "cutscene_after_scene_ready")
}
// forceTownSceneReentry 强制客户端完整重建城镇场景(方案 B)。
//
// 注意:2026-10-20 起本函数**不再被调用**。按需求去掉了「章节过场前的假场景切换」,
// 章节影片改由客户端补丁的内置播放器播放(见 armCutsceneAfterSceneReady /
// triggerClientCutsceneFile)。保留实现仅作回滚用:若要恢复"靠完整场景加载触发
// 原生播放",在 quest_finish_town_scene_refresh.go 的 townAreaOpened 分支里重新
// 加回 2 秒延迟调用即可。
//
// 已确认:客户端只在**完整场景加载**时自己播放章节过场(这也是小退重登能播的原因);
// 同区域 op24 的"局部重建"不会播。因此这里用「先转到同城镇的另一个区域、再转回原
// 区域」制造一次真正的场景切换 —— 客户端重建场景时会自己把该区域影片播出来。
//
// 只发 op24(场景转移),不改持久坐标、不动存档;玩家会看到一次很短的读图。
func (s *Service) forceTownSceneReentry(session *gameSession, townID, areaID byte,
snapshot currentTownPositionSnapshot, areaState int64) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
selfRow := currentSceneTransitionRow{
ObjectOrResourceKey: currentSceneActorObjectKey(session.selectedCharacterID),
Value1: snapshot.PositionX,
Value2: snapshot.PositionY,
Value3: snapshot.MovementCode,
Value4: byte(areaState),
}
// 中间区域:同城镇里换一个区域号(0/1 之间切换),再转回原区域。
intermediate := byte(0)
if areaID == 0 {
intermediate = 1
}
for index, target := range []byte{intermediate, areaID} {
body, err := buildCurrentSceneTransitionBody(townID, target,
[]currentSceneTransitionRow{selfRow})
if err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, body, 0); err != nil {
return err
}
s.logGameEvent(session, "game-upper-finish-quest-cutscene-scene-reentry",
"step", index+1, "town_id", townID, "area_id", target)
time.Sleep(500 * time.Millisecond)
}
return nil
}