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