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,280 @@
package dnfbridge
import (
"context"
"fmt"
"slices"
)
func (s *Service) captureRuntimeFiendWarRejoinPosition(session *gameSession) {
if !isCurrentFiendWarRaidChannel(session) {
return
}
session.townMu.Lock()
position := session.townPositionSnapshot
session.townMu.Unlock()
session.dungeon.mu.Lock()
if runtime := session.dungeon.runtime; runtime != nil && runtime.townReturnOrigin.PositionValid {
position = runtime.townReturnOrigin
}
session.dungeon.mu.Unlock()
if position.CharacterID != session.selectedCharacterID || !position.PositionValid || position.TownID != 98 || position.AreaID < 2 {
return
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(session.selectedCharacterID)
if state == nil || state.RaidMode != runtimeRaidOwnerFiendWar || state.Lifecycle.FiendWar == nil {
return
}
member, ok := runtimeRaidFrozenMember(*state, session.selectedCharacterID)
if !ok || state.Lifecycle.FiendWar.MemberArea(member.CharID, runtimeRaidNormalizedGroup(member.GroupIndex)) != int(position.AreaID) {
return
}
for i := range state.Members {
if state.Members[i].CharID == member.CharID {
state.Members[i].RejoinPosition = position
}
}
for i := range state.FrozenMembers {
if state.FrozenMembers[i].CharID == member.CharID {
state.FrozenMembers[i].RejoinPosition = position
}
}
}
func (s *Service) runtimeFiendWarRejoinLocation(session *gameSession, characterID uint16) (currentSceneTransitionRow, byte, string, bool, error) {
state, ok := s.runtimeRaidForMember(characterID)
if !ok || state.RaidMode != runtimeRaidOwnerFiendWar || !state.Started || state.Lifecycle.FiendWar == nil {
return currentSceneTransitionRow{}, 0, "", false, nil
}
member, ok := runtimeRaidFrozenMember(state, characterID)
if !ok {
return currentSceneTransitionRow{}, 0, "", false, errRuntimeRaidNotMember
}
b := state.Lifecycle.FiendWar
// A process reconnect is a fresh native Fiend War scene. The client must
// re-enter at the PVF waiting-room start, never at a stale pre-disconnect
// battle coordinate. Keep the old coordinate only as diagnostic state.
areaID := b.Config.WaitingArea
waiting, _ := s.townArea(int64(b.Config.WaitingTown), int64(areaID))
position := currentTownPositionSnapshot{}
for _, npc := range waiting.NPCs {
if slices.Contains(b.Config.WaitingNPCs, int(npc.NPCID)) && npc.X >= 0 && npc.X <= 32767 && npc.Y >= 0 && npc.Y <= 32767 {
position = currentTownPositionSnapshot{CharacterID: characterID, TownID: byte(b.Config.WaitingTown), AreaID: byte(areaID), PositionX: uint16(npc.X), PositionY: uint16(npc.Y), MovementCode: currentAntonRaidDirection, PositionValid: true}
break
}
}
area, found := s.townArea(int64(position.TownID), int64(position.AreaID))
if !position.PositionValid || !found || area.MapPath == "" {
return currentSceneTransitionRow{}, 0, "", true, fmt.Errorf("fiendwar rejoin position unavailable")
}
// 掉线宽限走本服务端既有的 raid_disconnect.go,不引入超时空专属的一套。
// 那套已经按 raid 自己的 PVF [DISCONN MEMBER WAIT TIME] 计时(超时空 etc 同样
// 声明 300),并在成员重连时由 restoreRuntimeRaidPresence 清掉预约,所以这里
// 不需要再做任何"取消保留"的动作。
s.raidMu.Lock()
current := s.raids[state.RaidKey]
if current == nil || current.StartAttempt != state.StartAttempt || runtimeRaidMemberIndex(*current, characterID) < 0 || current.Lifecycle.FiendWar == nil {
s.raidMu.Unlock()
return currentSceneTransitionRow{}, 0, "", true, errRuntimeRaidNotMember
}
if current.Lifecycle.FiendWar.MemberArea(characterID, runtimeRaidNormalizedGroup(member.GroupIndex)) != areaID {
battle := current.Lifecycle.FiendWar.Clone()
if battle.MemberAreas == nil {
battle.MemberAreas = make(map[uint16]int)
}
battle.MemberAreas[characterID] = areaID
group := runtimeRaidNormalizedGroup(member.GroupIndex)
peerAtPrevious := slices.ContainsFunc(current.FrozenMembers, func(peer runtimeRaidMemberState) bool {
return peer.CharID != characterID && runtimeRaidNormalizedGroup(peer.GroupIndex) == group && battle.MemberArea(peer.CharID, group) != areaID
})
if !peerAtPrevious {
battle.GroupAreas[group] = areaID
}
current.Lifecycle.FiendWar = battle
current.Lifecycle.Revision++
}
s.raidMu.Unlock()
row := currentSceneTransitionRow{ObjectOrResourceKey: currentSceneActorObjectKey(characterID), Value1: position.PositionX, Value2: position.PositionY, Value3: position.MovementCode, Value4: currentAntonRaidAreaState}
s.logGameEvent(session, "game-fiendwar-rejoin-location", "raid_key", state.RaidKey, "char_id", characterID, "town_id", position.TownID, "area_id", position.AreaID, "position_x", row.Value1, "position_y", row.Value2)
return row, position.AreaID, area.MapPath, true, nil
}
func (s *Service) removeRuntimeFiendWarMember(characterID uint16, raidKey uint32) (runtimeRaidState, error) {
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil || state.RaidMode != runtimeRaidOwnerFiendWar || runtimeRaidMemberIndex(*state, characterID) < 0 {
return runtimeRaidState{}, errRuntimeRaidNotMember
}
// 生命周期状态不再拦这一脚:名册里的人任何时刻都该能退(开团准备中、进行中、
// 通关/失败结算之后都一样)。原来只放 Preparing/Active,于是"打完/失败后再点退出"
// 与"名册只剩一个人"这些情形全被静默拒掉 —— 真机 2026-10-01 20:0x 报的就是它。
if state.Lifecycle.FiendWar == nil {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
state.Members = slices.DeleteFunc(state.Members, func(m runtimeRaidMemberState) bool { return m.CharID == characterID })
state.FrozenMembers = slices.DeleteFunc(state.FrozenMembers, func(m runtimeRaidMemberState) bool { return m.CharID == characterID })
battle := state.Lifecycle.FiendWar.Clone()
delete(battle.MemberAreas, characterID)
delete(battle.RecoveryUntil, characterID)
delete(battle.ImprisonCounts, characterID)
for group, area := range battle.GroupAreas {
peerIndex := slices.IndexFunc(state.FrozenMembers, func(m runtimeRaidMemberState) bool { return runtimeRaidNormalizedGroup(m.GroupIndex) == group })
if peerIndex < 0 {
delete(battle.GroupAreas, group)
} else if !slices.ContainsFunc(state.FrozenMembers, func(m runtimeRaidMemberState) bool {
return runtimeRaidNormalizedGroup(m.GroupIndex) == group && battle.MemberArea(m.CharID, group) == area
}) {
battle.GroupAreas[group] = battle.MemberArea(state.FrozenMembers[peerIndex].CharID, group)
}
}
state.Lifecycle.FiendWar = battle
delete(state.Lifecycle.FiendWarClearCounts, characterID)
if len(state.Members) == 0 {
state.Lifecycle.Status = runtimeRaidLifecycleAbandoned
delete(s.raids, raidKey)
} else if state.LeaderID == characterID {
state.LeaderID = state.Members[0].CharID
}
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), nil
}
// fiendWarLeaveKeyMatches 判断 op665 里那个 key 是不是"这个人自己"的表达。
//
// op665 一个包干两件事:自己退出带的 key 是**自己**(或攻坚队的 key,它的低 16 位就是
// 创建者的角色 id),队长踢人带的 key 是**被踢的那个人**(那条在上层就分流走了,而且
// 现在要求 key 确实是名册里的人)。
//
// 真机 2026-10-01 20:50 实测:名册只剩一个人之后,客户端发出来的 key 是 **3** ——
// 既不是自己(22)也不是攻坚队 key 的低 16 位(1)。所以这里接受 0、自己、攻坚队 key
// 三种"自己"的写法,**其余一律当作自己退出**:玩家按了退出攻坚队,就必须让他退出去,
// 不能因为一个读不懂的 key 把他晾在原地。
func fiendWarLeaveKeyMatches(state runtimeRaidState, characterID, requestKey uint16) bool {
switch requestKey {
case 0, characterID, uint16(state.RaidKey):
return true
}
return !fiendWarRequestKeyNamesAnotherMember(state, characterID, requestKey)
}
// fiendWarRequestKeyNamesAnotherMember 报告这个 key 是不是名册里**别人**的角色 id ——
// 只有这种形状才可能是踢人(那条已经在 handleOnlineLeaveRaid 里分流,走到这里时说明
// 发送者不是队长或目标不是成员,于是按自己退出处理)。
func fiendWarRequestKeyNamesAnotherMember(state runtimeRaidState, characterID, requestKey uint16) bool {
index := runtimeRaidMemberIndex(state, requestKey)
return index >= 0 && state.Members[index].CharID != characterID
}
func (s *Service) handleRuntimeFiendWarLeave(session *gameSession, typ, requestKey uint16, state runtimeRaidState) error {
// 退出攻坚队**永远要有回应**。以前这里有三个联合条件(key 必须严格等于 RaidKey 的
// 低 16 位、不能处于 StartPending、状态必须是 Preparing 或 Active),任一不满足就
// 只回一个失败包、连日志都不留 —— 玩家看到的就是"点了没反应"。
//
// 真机 2026-10-01 20:0x(操作员):名册只剩一个人之后点退出攻坚队,什么都没发生。
// 现在:key 放宽到上面那三种写法;**任何生命周期状态都接受**(打完、失败、放弃之后
// 同样得能退);拒绝时把原因写进日志。
if !fiendWarLeaveKeyMatches(state, session.selectedCharacterID, requestKey) {
s.logGameEvent(session, "game-fiendwar-leave-refused",
"raid_key", state.RaidKey,
"char_id", session.selectedCharacterID,
"request_key", requestKey,
"leader_id", state.LeaderID,
"member_count", len(state.Members),
"lifecycle_status", state.Lifecycle.Status,
"reason", "request_key_is_not_a_self_alias",
"source", "handle_runtime_fiendwar_leave")
return s.sendRaidCommandFailure(session, typ)
}
session.dungeon.mu.Lock()
inDungeon := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if inDungeon {
if err := s.handleDungeonBackToVillage(session, nil); err != nil {
return err
}
session.dungeon.mu.Lock()
inDungeon = session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if inDungeon {
return s.sendRaidCommandFailure(session, typ)
}
}
next, err := s.removeRuntimeFiendWarMember(session.selectedCharacterID, state.RaidKey)
if err != nil {
// 拒绝也要说清楚:日志里留一行,玩家能不能退就靠这条查。
s.logGameEvent(session, "game-fiendwar-leave-refused",
"raid_key", state.RaidKey,
"char_id", session.selectedCharacterID,
"request_key", requestKey,
"member_count", len(state.Members),
"lifecycle_status", state.Lifecycle.Status,
"start_pending", state.StartPending,
"reason", err.Error(),
"source", "handle_runtime_fiendwar_leave")
return s.sendRaidCommandFailure(session, typ)
}
storeRuntimeRaidEndedProjection(session, state.RaidKey)
// Remaining players must receive the new leader as well as the shorter roster.
if len(next.Members) > 0 {
if err := s.broadcastRuntimeRaidCreate(next); err != nil {
s.logGameEvent(session, "game-fiendwar-leave-peer-refresh-failed", "error", err)
}
if err := s.broadcastRuntimeRaidRefresh(next); err != nil {
s.logGameEvent(session, "game-fiendwar-leave-peer-refresh-failed", "error", err)
}
}
s.logGameEvent(session, "game-fiendwar-member-left", "raid_key", state.RaidKey, "char_id", session.selectedCharacterID, "remaining_members", len(next.Members))
return s.finishRuntimeFiendWarLeave(session, typ)
}
func (s *Service) finishRuntimeFiendWarLeave(session *gameSession, typ uint16) error {
// Serialize the native reset after any in-flight board publication.
session.fiendWarBoardMu.Lock()
err := s.sendRuntimeSingleRaidEnded(session, typ, "fiendwar_member_leave")
if err == nil {
session.fiendWarBoard = runtimeRaidState{}
}
session.fiendWarBoardMu.Unlock()
if err != nil {
return err
}
session.townMu.Lock()
position := session.townPositionSnapshot
session.townMu.Unlock()
if position.TownID != 98 || position.AreaID < 2 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
characterID, _, character, found := s.selectedCharacterForEnter(ctx, session)
if !found || characterID != session.selectedCharacterID {
return errRuntimeRaidNotMember
}
spec, _ := currentRaidTownSpecForSession(session)
_, town, area, row, path, err := s.currentRaidTownTransition(session, characterID, character, "fiendwar_leave_public_town", spec)
if err != nil {
return err
}
body, err := buildCurrentSceneTransitionBody(town, area, []currentSceneTransitionRow{row})
if err != nil {
return err
}
if err := s.sendCurrentTownLocationNotifications(session, characterID, town, area, row, "fiendwar_leave_public_town"); err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, body, 0); err != nil {
return err
}
s.cleanupOnlinePlayer(session)
clearCurrentDungeonSelectContext(session)
resetDungeonEntrySceneGates(session)
session.townMu.Lock()
setCurrentTownPositionSceneFromTransitionLocked(session, characterID, town, area, row)
session.townSceneReadyCharacterID = characterID
session.townMu.Unlock()
s.publishCurrentTownPresence(session, character, town, area, row, 0, path, "fiendwar_leave_public_town")
return nil
}