DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user