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,340 @@
package dnfbridge
import (
"encoding/binary"
)
// runtimeRaidLukePhaseCoinApplies 报告这名成员当前是否处在「队伍卢克」的已激活阶段里 ——
// 也就是 [PHASE COIN LIMIT] / [coin limit] 两层复活币上限该生效的场合。
//
// ⚠️ 这是本树对上游的**有意收窄**。上游把计币挂钩接到「所有非单人模式」的复活路径上;
// 真 PVF 实测(2026-09-29,runtime\data\dnf\Script.pvf,1450 个副本里 530 个声明了
// [coin limit])显示安徒恩的 210..229 全部声明 [coin limit] 12,而卢克一阶段的三个
// 圣所 5054/5055/5056 是 8、二阶段的关卡则没有这一节。若照搬上游,安徒恩会凭空多出
// 一层「每次进图 12 次」的封顶,改动用户已经验收过的复活币路径。
// 因此本树只对队伍卢克启用;安徒恩的复活路径逐字未动。
func (s *Service) runtimeRaidLukePhaseCoinApplies(characterID uint16) bool {
if s == nil || characterID == 0 {
return false
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil || state.TailFlag == 1 || !state.Started ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return false
}
return runtimeRaidTeamGraphOwner(*state) == runtimeRaidOwnerLuke
}
// Raid phase revive-coin cap.
//
// A phase that declares [PHASE COIN LIMIT] (only Luke phase two: 20) caps how
// many revive coins the roster may spend in it. The client enforces the cap on
// its own, before any packet: sub_1FA9D50 shows dstr 3015 「已超出复活币的使用
// 上限」 when, inside a raid map, `used >= limit`:
//
// - limit = raid singleton sub_962750()+0xE4 (getter sub_1361F10), loaded by the
// client from its own PVF; -1 means unlimited, which is why phase one (no
// [PHASE COIN LIMIT]) never trips it.
// - used = the local player's dungeon-party object (sub_1D935D0 of the owner)
// +0xE0, an obfuscated int read by sub_26392F0 and written by sub_2639250.
// sub_1FA94E0 returns it for the raid branch; +0xE8 (sub_26394C0 / setter
// sub_2639560) is the same counter for another mode.
//
// Nothing in the server ever set `used`, so it carried whatever it had collected
// before phase two and the first phase-two coin already failed (live
// 2026-09-23). Its only external writer is class-0 op659, runtime-registered
// with handler sub_12D02A0 (registration 0x12D0483, through sub_225C940, which
// overrides the static table's op659): body u16 member, u32 A -> +0xE8,
// u32 B -> +0xE0. The handler bails unless the dungeon-party object exists (in a
// dungeon only), and writes only when `member` equals the encoded global
// 0x52B5DE4 -- the local player's party-member id (compared with a party member
// row's u16 +0x38 at 0xA4D1F3; initialised to 0xFFFF by 0x40CA040).
//
// The situation board's 「攻坚队复活币」 is a different field again: the 0x027A
// monster-state row's Value0, S4A21's UsedCoinCount (raid_monster_state.go).
//
// So the server keeps the phase's roster-wide count and projects it:
// - after every dungeon FINISH_LOADING, to the member that just loaded;
// - after every accepted coin, to every online member;
// - and it refuses a coin itself once the cap is reached.
// Each member gets two copies, one keyed by its character id (what this server
// uses as party-member id) and one keyed 0xFFFF (the global's initial value, in
// case the client never assigned it); a copy whose key does not match is ignored
// by the client.
const (
runtimeRaidPhaseCoinUsageMsgID uint16 = 659
runtimeRaidPhaseCoinUsageUnsetKey uint16 = 0xFFFF
// runtimeRaidSquadCoinUsedMsgID is class-0 op775: handler sub_1D575D0
// (registered at 0x1D7F8AD) reads one u8 and stores it through
// sub_1E69F00 into the raid-member manager [0x51B2748]+0x194.
//
// That field is what the client really counts once the member sits in a
// squad with a coin total (sub_17C3120 != -1): sub_1FA94E0 then returns it
// as "used", and the situation board's 「攻坚队复活币」 shows total minus it.
// The server never sent op775, so the field held heap garbage: live
// 2026-09-24 the F9 probe read 220531861 on one client (revive refused
// silently) and -2009364085 on the other (revive allowed), with op659's
// +0xE0 correctly at 2 on both.
runtimeRaidSquadCoinUsedMsgID uint16 = 775
)
// runtimeRaidSquadCoinUsed is the op775 value for a raid: the phase count where
// the phase is capped, 0 otherwise -- never left unset.
func runtimeRaidSquadCoinUsed(state runtimeRaidState) byte {
if runtimeRaidPhaseCoinLimit(state) <= 0 || state.Lifecycle.PhaseCoinsUsed <= 0 {
return 0
}
if state.Lifecycle.PhaseCoinsUsed > 0xFF {
return 0xFF
}
return byte(state.Lifecycle.PhaseCoinsUsed)
}
func runtimeRaidSquadCoinUsedNotification(state runtimeRaidState) runtimeRaidWireNotification {
return runtimeRaidWireNotification{
msgID: runtimeRaidSquadCoinUsedMsgID,
body: []byte{runtimeRaidSquadCoinUsed(state)},
}
}
func buildRuntimeRaidPhaseCoinUsageBody(memberKey uint16, used uint32) []byte {
body := make([]byte, 10)
binary.LittleEndian.PutUint16(body[0:2], memberKey)
binary.LittleEndian.PutUint32(body[2:6], used)
binary.LittleEndian.PutUint32(body[6:10], used)
return body
}
// runtimeRaidPhaseCoinLimit is the active phase's [PHASE COIN LIMIT], or 0 when
// the phase has none or the raid is not in an active phase.
func runtimeRaidPhaseCoinLimit(state runtimeRaidState) int {
if !state.Started || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return 0
}
phase, _, ok := runtimeRaidPhaseConfig(state, state.Lifecycle.Phase)
if !ok {
return 0
}
return phase.CoinLimit
}
type runtimeRaidPhaseCoinStatus struct {
RaidKey uint32
Limit int
Used int
// MapLimit / MapUsed are the dungeon's own [coin limit] for the entry the
// member is in (Luke phase one: 8 per sanctum entry), 0 when it has none.
MapLimit int
MapUsed int
// RunKey is the entry the map count was charged to.
RunKey runtimeRaidDungeonRunKey
MapCapped bool
// Refused names the cap that stopped the coin: "phase" or "map".
Refused string
}
// runtimeRaidPhaseCoinStatusForMember reports the member's capped phase, if any.
func (s *Service) runtimeRaidPhaseCoinStatusForMember(characterID uint16) (runtimeRaidPhaseCoinStatus, bool) {
if s == nil || characterID == 0 {
return runtimeRaidPhaseCoinStatus{}, false
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil {
return runtimeRaidPhaseCoinStatus{}, false
}
limit := runtimeRaidPhaseCoinLimit(*state)
if limit <= 0 {
return runtimeRaidPhaseCoinStatus{}, false
}
return runtimeRaidPhaseCoinStatus{RaidKey: state.RaidKey, Limit: limit, Used: state.Lifecycle.PhaseCoinsUsed}, true
}
// commitRuntimeRaidPhaseCoin charges one coin against both raid caps: the
// phase's [PHASE COIN LIMIT] (roster-wide) and the dungeon's own [coin limit]
// for the entry the member is fighting (squad-wide, reset by re-entry).
// dungeonCoinLimit is the current dungeon's [coin limit], 0 for none. It
// refuses when either cap is reached; ok is false when neither applies.
func (s *Service) commitRuntimeRaidPhaseCoin(characterID uint16, mapID int, dungeonCoinLimit int) (status runtimeRaidPhaseCoinStatus, ok bool, allowed bool) {
if s == nil || characterID == 0 {
return runtimeRaidPhaseCoinStatus{}, false, true
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil || !state.Started || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return runtimeRaidPhaseCoinStatus{}, false, true
}
status = runtimeRaidPhaseCoinStatus{
RaidKey: state.RaidKey,
Limit: runtimeRaidPhaseCoinLimit(*state),
Used: state.Lifecycle.PhaseCoinsUsed,
}
var progress runtimeRaidDungeonProgress
if dungeonCoinLimit > 0 && mapID > 0 {
if member, found := runtimeRaidFrozenMember(*state, characterID); found {
key := runtimeRaidDungeonRunKey{MapID: mapID, Seat: runtimeRaidSeatFor(member)}
if current, exists := state.Lifecycle.Dungeons[key]; exists {
progress = current
status.RunKey = key
status.MapCapped = true
status.MapLimit = dungeonCoinLimit
status.MapUsed = current.CoinsUsed
}
}
}
phaseCapped := status.Limit > 0
if !phaseCapped && !status.MapCapped {
return runtimeRaidPhaseCoinStatus{}, false, true
}
if phaseCapped && status.Used >= status.Limit {
status.Refused = "phase"
return status, true, false
}
if status.MapCapped && status.MapUsed >= status.MapLimit {
status.Refused = "map"
return status, true, false
}
if phaseCapped {
state.Lifecycle.PhaseCoinsUsed++
status.Used = state.Lifecycle.PhaseCoinsUsed
}
if status.MapCapped {
progress.CoinsUsed++
state.Lifecycle.Dungeons[status.RunKey] = progress
status.MapUsed = progress.CoinsUsed
}
state.Lifecycle.Revision++
return status, true, true
}
// refundRuntimeRaidPhaseCoin undoes a charge whose revive then failed (wallet
// error or no coin), so a refused revive does not eat into either cap.
func (s *Service) refundRuntimeRaidPhaseCoin(status runtimeRaidPhaseCoinStatus) {
if s == nil || status.RaidKey == 0 {
return
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[status.RaidKey]
if state == nil {
return
}
if status.Limit > 0 && state.Lifecycle.PhaseCoinsUsed > 0 {
state.Lifecycle.PhaseCoinsUsed--
}
if status.MapCapped {
if progress, exists := state.Lifecycle.Dungeons[status.RunKey]; exists && progress.CoinsUsed > 0 {
progress.CoinsUsed--
state.Lifecycle.Dungeons[status.RunKey] = progress
}
}
state.Lifecycle.Revision++
}
// sendRuntimeRaidPhaseCoinUsage writes both keyed copies to one member.
func (s *Service) sendRuntimeRaidPhaseCoinUsage(session *gameSession, used int, source string) {
if s == nil || session == nil || used < 0 {
return
}
characterID := session.selectedCharacterID
for _, key := range []uint16{currentSceneActorObjectKey(characterID), runtimeRaidPhaseCoinUsageUnsetKey} {
if err := s.sendGameUpperRawClass(
session,
runtimeRaidPhaseCoinUsageMsgID,
buildRuntimeRaidPhaseCoinUsageBody(key, uint32(used)),
0,
); err != nil {
s.logGameEvent(session, "game-raid-phase-coin-usage-send-failed",
"source", source, "char_id", characterID, "member_key", key, "error", err)
return
}
}
s.logGameEvent(session, "game-raid-phase-coin-usage-sent",
"source", source,
"char_id", characterID,
"used", used,
"response_opcode", runtimeRaidPhaseCoinUsageMsgID,
"member_keys", "char_id_and_0xffff")
}
// syncRuntimeRaidPhaseCoinUsageAfterFinishLoading projects the phase count onto a
// member whose dungeon just finished loading: the client's counter lives on the
// dungeon-party object, which exists only now.
func (s *Service) syncRuntimeRaidPhaseCoinUsageAfterFinishLoading(session *gameSession, source string) {
if s == nil || session == nil {
return
}
session.dungeon.mu.Lock()
inDungeon := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if !inDungeon {
return
}
if state, ok := s.runtimeRaidForMember(session.selectedCharacterID); ok && state.Started && state.TailFlag != 1 {
// op775 goes out for every phase, capped or not: the field it sets is
// otherwise uninitialised and decides whether the client lets a coin go.
notification := runtimeRaidSquadCoinUsedNotification(state)
if err := s.sendGameUpperRawClass(session, notification.msgID, notification.body, 0); err != nil {
s.logGameEvent(session, "game-raid-squad-coin-used-send-failed",
"source", source, "char_id", session.selectedCharacterID, "error", err)
}
}
status, capped := s.runtimeRaidPhaseCoinStatusForMember(session.selectedCharacterID)
if !capped {
return
}
s.sendRuntimeRaidPhaseCoinUsage(session, status.Used, source)
}
// broadcastRuntimeRaidPhaseCoinUsage pushes a new count to every online member.
// Members outside a dungeon drop it on the client and are caught up by their
// next FINISH_LOADING.
func (s *Service) broadcastRuntimeRaidPhaseCoinUsage(raidKey uint32, used int, source string) {
if s == nil || raidKey == 0 {
return
}
s.raidMu.Lock()
state := s.raids[raidKey]
members := make([]uint16, 0)
if state != nil {
for _, member := range state.FrozenMembers {
members = append(members, member.CharID)
}
if len(members) == 0 {
for _, member := range state.Members {
members = append(members, member.CharID)
}
}
}
var snapshot runtimeRaidState
if state != nil {
snapshot = cloneRuntimeRaidState(*state)
}
s.raidMu.Unlock()
for _, characterID := range members {
session, online := s.onlineGameSession(characterID)
if !online {
continue
}
s.sendRuntimeRaidPhaseCoinUsage(session, used, source)
}
// op775 carries the count the client actually checks and the board shows;
// the 0x027A rows carry it too (Value0, S4A21's UsedCoinCount).
if state != nil {
if err := s.broadcastRuntimeRaidWireSequence(snapshot, runtimeRaidSquadCoinUsedNotification(snapshot)); err != nil {
s.logPacketEvent("game-raid-squad-coin-used-broadcast-failed",
"raid_key", raidKey, "source", source, "error", err)
}
// 本树这一版的 broadcastRuntimeRaidMonsterState 只收 state(上游那版多一个
// source 参数,只用于日志)。这里按本树签名调用,行为一致。
if err := s.broadcastRuntimeRaidMonsterState(snapshot); err != nil {
s.logPacketEvent("game-raid-phase-coin-board-refresh-failed",
"raid_key", raidKey, "source", source, "error", err)
}
}
}