DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,387 @@
// 本文件负责 PVF 教堂预约、入场和持久化仪式阶段的计时恢复。
package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/wedding"
)
func (s *Service) weddingRoom() (byte, error) {
for room := byte(8); room <= 38; room++ {
if area, found := s.townArea(39, int64(room)); found && strings.EqualFold(strings.ReplaceAll(area.MapPath, "\\", "/"), "Church/Church.map") {
return room, nil
}
}
return 0, fmt.Errorf("wedding church missing from runtime PVF")
}
func weddingTownInstance(c dnfrepo.CharacterRecord, room byte, channel uint16) (uint32, bool) {
state, err := wedding.Read(c)
if err != nil || (state.Status != 2 && state.Status != 3) || state.Room != room || state.ChannelID != channel {
return 0, false
}
return state.PairID, true
}
// currentWeddingGuildDungeon 对应上游的 runtime.guildDungeonID != 0。
//
// 工作区的 runtimeDungeonState 不记"这是不是公会地下城"这个标志:公会净化是通关后
// 由 creditCurrentGuildDungeonClear 用同一份目录反查入账的。所以这里复用同一个
// 判据(目录里能查到该副本 id 即为公会地下城),保持"公会本不算夫妻通关"的语义。
// 目录加载失败时返回 false —— 宁可漏排除,也不要因为读目录失败而吞掉正常通关。
//
// 判据本体已提到 currentGuildDungeonRun(公会贡献度那条规则也要用同一个判据),
// 这里只是保留夫妻系统原有的函数名与语义,行为逐字节不变。
func (s *Service) currentWeddingGuildDungeon(runtime *runtimeDungeonState) bool {
return s.currentGuildDungeonRun(runtime)
}
// The dungeon owner supplies the actual completion roster, not a town party list.
func (s *Service) currentWeddingDungeonCouple(ctx context.Context, runtime *runtimeDungeonState) (bool, error) {
if runtime == nil || runtime.ProtocolMode != currentDungeonProtocolModeNormal ||
runtime.raidDungeon.RaidKey != 0 || s.currentWeddingGuildDungeon(runtime) || runtime.altar != nil ||
runtime.tournament != nil || runtime.Dungeon.Metadata.NoFatigue ||
runtime.Dungeon.Metadata.TutorialDungeon.Value != 0 || runtime.Dungeon.Metadata.InfernoDungeon {
return false, nil
}
kind := normalizeDungeonPVFSymbol(runtime.Dungeon.Metadata.DungeonType)
if kind != "" && kind != "normal" {
return false, nil
}
owner := runtime.partyDungeonOwner
if owner == nil {
return false, nil
}
owner.mu.Lock()
defer owner.mu.Unlock()
// Freeze both recipients' eligibility at the first authoritative completion,
// so the first member returning to town does not invalidate the other's result.
if owner.weddingClearKnown {
return owner.weddingClearCouple, nil
}
if owner.retired || owner.entryMemberCount != 2 {
return false, nil
}
members := owner.members
if len(members) != 2 {
return false, nil
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
return false, dnfrepo.ErrRepoMissing
}
var states [2]wedding.State
containsActor := false
for i, member := range members {
identity, online := s.onlineGameSessionCharacterSnapshot(member.userID)
if !online || identity.generation != member.generation {
return false, nil
}
id := strconv.Itoa(int(member.userID))
containsActor = containsActor || id == runtime.Character.CharacterID
character, found, err := repos.Character.Load(ctx, id)
if err != nil {
return false, err
}
if !found || character.AccountID != s.accountIDForSession(identity.session) || character.Stats["delete_flag"] != 0 {
return false, nil
}
states[i], err = wedding.Read(character)
if err != nil || states[i].Status != 1 {
return false, nil
}
}
owner.weddingClearCouple = containsActor && states[0].PairID == states[1].PairID &&
states[0].PartnerID == members[1].userID && states[1].PartnerID == members[0].userID
owner.weddingClearKnown = true
return owner.weddingClearCouple, nil
}
func (s *Service) weddingGiftAllocator(cat *wedding.Catalog, kind byte) (func(*dnfrepo.InventoryRecord, wedding.Item) error, error) {
if kind > 2 {
return nil, wedding.Invalid
}
items, err := s.currentPVFItemCatalog()
if err != nil {
return nil, err
}
gift := cat.Ceremonies[kind].Gift
definition, err := items.ResolveItem(uint32(gift.ID))
if err != nil {
return nil, err
}
return func(bag *dnfrepo.InventoryRecord, item wedding.Item) error {
if item != gift {
return wedding.Invalid
}
_, err := s.grantCurrentWeddingMainProduct(bag, definition, uint32(item.Count))
return err
}, nil
}
func (s *Service) restoreWeddingCeremony(session *gameSession, c dnfrepo.CharacterRecord) error {
state, err := wedding.Read(c)
if err != nil {
return err
}
if state.Status != 2 && state.Status != 3 {
return nil
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
return dnfrepo.ErrRepoMissing
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
partner, found, err := repos.Character.Load(ctx, strconv.Itoa(int(state.PartnerID)))
if err != nil {
return err
}
if !found {
return wedding.Missing
}
var publishErr error
if state.Status == 2 && s.gameplayNow().Before(time.Unix(state.StartAt, 0)) {
publishErr = s.publishWeddingReservation([2]dnfrepo.CharacterRecord{c, partner})
}
return errors.Join(publishErr, s.scheduleWeddingCeremony(c))
}
func weddingReservationBody(chars [2]dnfrepo.CharacterRecord, state wedding.State, channel uint32, invitations uint16) []byte {
var w packetWriter
w.writeUint32(state.PairID)
w.writeUint32(uint32(state.Room))
w.writeUint32(uint32(state.StartAt))
w.writeUint32(channel)
w.writeByte(state.Ceremony)
for _, c := range chars {
name := rosterNameBytes(c.Name)
w.writeBytes(name)
w.writeZero(30 - len(name))
}
w.writeUint16(invitations)
return w.bytes()
}
func (s *Service) publishWeddingReservation(chars [2]dnfrepo.CharacterRecord) error {
state, err := wedding.Read(chars[0])
if err != nil {
return err
}
cat, err := s.currentWeddingCatalog()
if err != nil {
return err
}
for _, c := range chars {
if len(rosterNameBytes(c.Name)) > 29 {
return wedding.Invalid
}
}
for _, c := range chars {
id, _ := strconv.ParseUint(c.CharacterID, 10, 16)
identity, online := s.onlineGameSessionCharacterSnapshot(uint16(id))
if online {
err = errors.Join(err, s.sendGameUpperRawClass(identity.session, currentWeddingReservationMsgID, weddingReservationBody(chars, state, uint32(state.ChannelID), uint16(cat.Ceremonies[state.Ceremony].Invitations)), 0))
}
}
return err
}
func (s *Service) sendWeddingInventorySnapshot(session *gameSession, bag dnfrepo.InventoryRecord) error {
if bag.CharacterID == "" {
return nil
}
// Only main-bag contents changed; retain all other native containers and equipped objects.
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
body, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, 0)
if !ok {
return fmt.Errorf("wedding inventory snapshot missing")
}
return s.sendGameUpperRawClass(session, 13, body, 0)
}
func (s *Service) scheduleWeddingCeremony(c dnfrepo.CharacterRecord) error {
state, err := wedding.Read(c)
if err != nil {
return err
}
if state.Status != 2 && state.Status != 3 {
return nil
}
cat, err := s.currentWeddingCatalog()
if err != nil {
return err
}
at := state.StartAt
if state.Status == 3 {
at += cat.EndSeconds
}
key := fmt.Sprintf("dnf-wedding:%d:%d", state.PairID, state.StartAt)
return s.ensureGameplayTimeQueue().ScheduleAfter(key, max(time.Duration(0), time.Unix(at, 0).Sub(s.gameplayNow())), func(time.Time) {
if err := s.advanceWeddingCeremony(c.CharacterID, state.PairID, state.StartAt); err != nil {
s.logWarn("wedding ceremony deferred", "character_id", c.CharacterID, "error", err)
// A full bag or transient DB failure leaves the stage intact; retry without charging again.
_ = s.ensureGameplayTimeQueue().ScheduleAfter(key, 30*time.Second, func(time.Time) {
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
fresh, found, e := repos.Character.Load(ctx, c.CharacterID)
if e == nil && found {
_ = s.scheduleWeddingCeremony(fresh)
}
})
}
})
}
func (s *Service) advanceWeddingCeremony(characterID string, pair uint32, start int64) error {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
s.weddingMu.Lock()
defer s.weddingMu.Unlock()
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
return dnfrepo.ErrRepoMissing
}
c, found, err := repos.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return wedding.Missing
}
state, err := wedding.Read(c)
if err != nil {
return err
}
if state.PairID != pair || state.StartAt != start || (state.Status != 2 && state.Status != 3) {
return nil
}
cat, err := s.currentWeddingCatalog()
if err != nil {
return err
}
cmd := wedding.Command{ActorID: characterID, PartnerID: strconv.Itoa(int(state.PartnerID)), AccountID: c.AccountID, Operation: 3, Now: s.gameplayNow()}
if state.Status == 3 {
cmd.Operation = 4
cmd.Grant, err = s.weddingGiftAllocator(cat, state.Ceremony)
if err != nil {
return err
}
}
result, err := wedding.Commit(ctx, repos, cat, cmd)
if err != nil {
return err
}
var publishErr error
if cmd.Operation == 3 {
publishErr = s.publishWeddingCommit(result, 3)
for _, peer := range s.weddingCeremonyPeers(state) {
var w packetWriter
w.writeUint32(pair)
w.writeUint32(uint32(state.Room))
for _, character := range result.Characters {
name := rosterNameBytes(character.Name)
w.writeBytes(name)
w.writeZero(30 - len(name))
}
publishErr = errors.Join(publishErr, s.sendGameUpperRawClass(peer, currentWeddingStartMsgID, w.bytes(), 0))
}
return errors.Join(publishErr, s.scheduleWeddingCeremony(result.Characters[0]))
}
publishErr = s.publishWeddingCommit(result, 4)
for i, character := range result.Characters {
id, _ := strconv.ParseUint(character.CharacterID, 10, 16)
if identity, online := s.onlineGameSessionCharacterSnapshot(uint16(id)); online {
publishErr = errors.Join(publishErr, s.sendWeddingInventorySnapshot(identity.session, result.Inventories[i]))
}
}
// sub_1837CC0 end notification removes the ceremony object and invokes the native exit route.
for _, peer := range s.weddingCeremonyPeers(state) {
var w packetWriter
w.writeByte(1)
w.writeByte(state.Ceremony)
for _, character := range result.Characters {
name := rosterNameBytes(character.Name)
w.writeBytes(name)
w.writeZero(30 - len(name))
}
w.writeUint32(uint32(state.Room))
w.writeByte(state.Ceremony)
w.writeUint32(uint32(peer.channel.ID))
publishErr = errors.Join(publishErr, s.sendGameUpperRawClass(peer, currentWeddingEndMsgID, w.bytes(), 0))
}
return publishErr
}
func (s *Service) weddingCeremonyPeers(state wedding.State) []*gameSession {
if s.onlinePlayers == nil {
return nil
}
var peers []*gameSession
for _, p := range s.onlinePlayers.GetAreaInstancePlayers(state.ChannelID, 39, state.Room, state.PairID) {
if p.Session != nil {
peers = append(peers, p.Session)
}
}
return peers
}
func (s *Service) handleWeddingEnter(session *gameSession, body []byte) error {
if len(body) != 22 {
return nil
}
identity, current := s.boundGameSessionCharacterSnapshot(session)
if !current {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
c, err := s.weddingCharacter(ctx, identity)
state, stateErr := wedding.Read(c)
if err == nil {
err = stateErr
}
if err == nil && (body[21] != 0 || state.Status != 2 || uint16(session.channel.ID) != state.ChannelID || binary.LittleEndian.Uint32(body[13:17]) != state.PairID || binary.LittleEndian.Uint32(body[17:21]) != uint32(state.Room) || !s.gameplayNow().Before(time.Unix(state.StartAt, 0))) {
err = wedding.Error(-6)
}
cat, catErr := s.currentWeddingCatalog()
if err == nil {
err = catErr
}
if err == nil {
index := 0
if c.CharacterID > strconv.Itoa(int(state.PartnerID)) {
index = 1
}
pos := cat.Positions[index]
err = s.handleTownSetUserArea(session, buildCurrentSoloTeleportMoveBody(currentSoloTeleportRequest{TownID: 39, AreaID: state.Room, PositionX: int16(pos[0]), PositionY: int16(pos[1]), Direction: 5}, uint16(c.Stats["town_id"])))
if err == nil {
session.townMu.Lock()
p := session.townPositionSnapshot
session.townMu.Unlock()
if p.TownID != 39 || p.AreaID != state.Room {
err = wedding.Error(-6)
}
}
}
var code wedding.Error
if err != nil && !errors.As(err, &code) {
code = wedding.Error(-6)
}
var w packetWriter
w.writeInt32(int(code))
return s.sendGameUpperSuccess(session, 1039, w.bytes())
}