服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
301 lines
11 KiB
Go
301 lines
11 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfinventory "longheng.io/server/internal/modules/dnf/inventory"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
// The current EXE does not spend a dungeon HP/MP potion through op44
|
|
// USE_STACKABLE. It sends class-1/op18 DELETE_ITEM to remove the dose and then,
|
|
// a few milliseconds later, class-1/op38 USE_SKILL carrying a fixed skill
|
|
// number for the drink animation. op38 has no bridge owner beyond the local
|
|
// debug strike hook, so the dose was consumed and no recovery ever came back:
|
|
// the bar did not move. This pairs the two commands and answers the second one.
|
|
const (
|
|
// currentPotionCastSkillID is the op38 skill number the client uses for a
|
|
// consumable, big-endian in the first two body bytes.
|
|
currentPotionCastSkillID uint16 = 24
|
|
|
|
// currentPotionCastWindow bounds the gap between the op18 commit and the
|
|
// op38 cast. The pair arrives milliseconds apart; the allowance is wide
|
|
// enough to survive a stalled frame and short enough that an unrelated cast
|
|
// cannot claim a stale delete.
|
|
currentPotionCastWindow = 2 * time.Second
|
|
|
|
// currentPotionRecoveryFlatOperator and currentPotionRecoveryPercentOperator
|
|
// are the two operators the runtime PVF actually uses in a recovery section.
|
|
currentPotionRecoveryFlatOperator = "+"
|
|
currentPotionRecoveryPercentOperator = "%"
|
|
|
|
currentPotionHPMaxStatKey = "stat_hp_max"
|
|
currentPotionMPMaxStatKey = "stat_mp_max"
|
|
)
|
|
|
|
// currentPotionCastState records the most recent committed dungeon consumable
|
|
// delete so the cast that follows it can be identified.
|
|
type currentPotionCastState struct {
|
|
mu sync.Mutex
|
|
pending *currentPendingConsumableUse
|
|
}
|
|
|
|
type currentPendingConsumableUse struct {
|
|
itemID uint32
|
|
listType byte
|
|
slotIndex int16
|
|
committedAt time.Time
|
|
}
|
|
|
|
// noteCurrentConsumableDeleteFromCommand records the first entry of a committed
|
|
// op18 request. A decode failure only skips the record: this runs after the
|
|
// delete has already been committed, and the potion chain must never be able to
|
|
// disturb the inventory path it observes.
|
|
func (s *Service) noteCurrentConsumableDeleteFromCommand(session *gameSession, body []byte) {
|
|
parsed, err := dnfinventory.DecodeDeleteRequest(body)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if len(parsed.Entries) > 0 {
|
|
entry := parsed.Entries[0]
|
|
s.noteCurrentConsumableDelete(session, parsed.ListType, entry.SlotIndex, int64(entry.ItemID))
|
|
return
|
|
}
|
|
s.noteCurrentConsumableDelete(session, parsed.ListType, parsed.SlotIndex, 0)
|
|
}
|
|
|
|
func (s *Service) noteCurrentConsumableDelete(session *gameSession, listType byte, slotIndex int16, itemID int64) {
|
|
if session == nil || itemID <= 0 {
|
|
return
|
|
}
|
|
session.potionCast.mu.Lock()
|
|
defer session.potionCast.mu.Unlock()
|
|
session.potionCast.pending = ¤tPendingConsumableUse{
|
|
itemID: uint32(itemID),
|
|
listType: listType,
|
|
slotIndex: slotIndex,
|
|
committedAt: s.gameplayNow(),
|
|
}
|
|
}
|
|
|
|
// takeCurrentConsumableDeleteIfFresh consumes the pending record. It is taken
|
|
// whether or not it is still fresh, so one delete can never answer two casts.
|
|
func (session *gameSession) takeCurrentConsumableDeleteIfFresh(now time.Time) *currentPendingConsumableUse {
|
|
session.potionCast.mu.Lock()
|
|
defer session.potionCast.mu.Unlock()
|
|
pending := session.potionCast.pending
|
|
session.potionCast.pending = nil
|
|
if pending == nil || now.Sub(pending.committedAt) > currentPotionCastWindow {
|
|
return nil
|
|
}
|
|
return pending
|
|
}
|
|
|
|
// handleCurrentDungeonPotionCast answers an op38 cast that follows a committed
|
|
// consumable delete. It reports whether it owned the cast; an ordinary skill
|
|
// cast is left to the existing dispatch, which has no server obligation for it.
|
|
func (s *Service) handleCurrentDungeonPotionCast(session *gameSession, body []byte) (bool, error) {
|
|
if s == nil || session == nil {
|
|
return false, nil
|
|
}
|
|
skillID, ok := parseCurrentDungeonUseSkillSkillID(body)
|
|
if !ok || skillID != currentPotionCastSkillID {
|
|
return false, nil
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
inDungeon := session.dungeon.runtime != nil
|
|
session.dungeon.mu.Unlock()
|
|
if !inDungeon {
|
|
return false, nil
|
|
}
|
|
pending := session.takeCurrentConsumableDeleteIfFresh(s.gameplayNow())
|
|
if pending == nil {
|
|
// Skill 24 outside the pairing window is not a potion. Stay silent and
|
|
// let the ordinary dispatch have it.
|
|
return false, nil
|
|
}
|
|
|
|
block := func(reason string, fields ...any) (bool, error) {
|
|
fields = append([]any{
|
|
"char_id", selectedCharacterIDString(session),
|
|
"item_id", pending.itemID,
|
|
"list_type", pending.listType,
|
|
"slot", pending.slotIndex,
|
|
"reason", reason,
|
|
}, fields...)
|
|
s.logGameEvent(session, "game-dungeon-potion-cast-blocked", fields...)
|
|
return true, nil
|
|
}
|
|
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return block("runtime_pvf_catalog_unavailable", "error", err.Error())
|
|
}
|
|
definition, err := catalog.ResolveItem(pending.itemID)
|
|
if err != nil {
|
|
return block("item_definition_unresolved", "error", err.Error())
|
|
}
|
|
if !currentPotionWasteStackable(definition.StackableType) {
|
|
return block("deleted_item_is_not_a_waste_consumable", "stackable_type", definition.StackableType, "pvf_path", definition.PVFPath)
|
|
}
|
|
if !definition.HasHPRecovery && !definition.HasMPRecovery {
|
|
return block("deleted_consumable_declares_no_recovery", "pvf_path", definition.PVFPath)
|
|
}
|
|
|
|
character, found, err := s.loadCurrentPotionDrinker(session)
|
|
if err != nil || !found {
|
|
reason := "drinker_record_unavailable"
|
|
if err != nil {
|
|
return block(reason, "error", err.Error())
|
|
}
|
|
return block(reason)
|
|
}
|
|
|
|
hpAmount, hpErr := currentPotionRecoveryAmount(
|
|
definition.HasHPRecovery, definition.HPRecoveryOperator, definition.HPRecoveryAmount,
|
|
character, currentPotionHPMaxStatKey)
|
|
mpAmount, mpErr := currentPotionRecoveryAmount(
|
|
definition.HasMPRecovery, definition.MPRecoveryOperator, definition.MPRecoveryAmount,
|
|
character, currentPotionMPMaxStatKey)
|
|
if hpErr != "" || mpErr != "" {
|
|
reason := hpErr
|
|
if reason == "" {
|
|
reason = mpErr
|
|
}
|
|
return block(reason,
|
|
"pvf_path", definition.PVFPath,
|
|
"hp_operator", definition.HPRecoveryOperator, "hp_amount", definition.HPRecoveryAmount,
|
|
"mp_operator", definition.MPRecoveryOperator, "mp_amount", definition.MPRecoveryAmount)
|
|
}
|
|
if hpAmount == 0 && mpAmount == 0 {
|
|
return block("recovery_resolved_to_zero", "pvf_path", definition.PVFPath)
|
|
}
|
|
|
|
objectKey := currentSceneActorObjectKey(session.selectedCharacterID)
|
|
if err := s.sendCurrentDungeonPotionRecovery(session, objectKey, hpAmount, mpAmount); err != nil {
|
|
return true, err
|
|
}
|
|
s.logGameEvent(session, "game-dungeon-potion-cast-applied",
|
|
"char_id", selectedCharacterIDString(session),
|
|
"object_key", objectKey,
|
|
"item_id", pending.itemID,
|
|
"pvf_path", definition.PVFPath,
|
|
"hp_operator", definition.HPRecoveryOperator,
|
|
"hp_declared", definition.HPRecoveryAmount,
|
|
"hp_applied", hpAmount,
|
|
"mp_operator", definition.MPRecoveryOperator,
|
|
"mp_declared", definition.MPRecoveryAmount,
|
|
"mp_applied", mpAmount,
|
|
"msg_id", uint16(dnfenum.CmdPacketNotifyDieMonster),
|
|
"response", "current_exe_op38_mode1_recovery")
|
|
return true, nil
|
|
}
|
|
|
|
func (s *Service) loadCurrentPotionDrinker(session *gameSession) (dnfrepo.CharacterRecord, bool, error) {
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil {
|
|
return dnfrepo.CharacterRecord{}, false, nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
return repositories.Character.Load(ctx, selectedCharacterIDString(session))
|
|
}
|
|
|
|
// currentPotionRecoveryAmount converts one declared recovery into the scalar the
|
|
// recovery frame carries. It returns a non-empty reason instead of guessing:
|
|
// a percentage with no known maximum on the character record would otherwise
|
|
// have to invent the drinker's HP pool.
|
|
func currentPotionRecoveryAmount(
|
|
declared bool,
|
|
operator string,
|
|
amount int64,
|
|
character dnfrepo.CharacterRecord,
|
|
maxStatKey string,
|
|
) (uint16, string) {
|
|
if !declared || amount <= 0 {
|
|
// A non-positive amount is a damage or no-op declaration - the archive
|
|
// has `% -7` on a [virtual] item - and never a heal.
|
|
return 0, ""
|
|
}
|
|
switch strings.TrimSpace(operator) {
|
|
case currentPotionRecoveryFlatOperator:
|
|
return clampCurrentPotionAmount(amount), ""
|
|
case currentPotionRecoveryPercentOperator:
|
|
maximum, found := statInt64OK(character, maxStatKey)
|
|
if !found || maximum <= 0 {
|
|
return 0, "percent_recovery_without_known_maximum"
|
|
}
|
|
return clampCurrentPotionAmount(maximum * amount / 100), ""
|
|
default:
|
|
return 0, "unproven_recovery_operator"
|
|
}
|
|
}
|
|
|
|
func clampCurrentPotionAmount(value int64) uint16 {
|
|
if value <= 0 {
|
|
return 0
|
|
}
|
|
if value > int64(^uint16(0)) {
|
|
return ^uint16(0)
|
|
}
|
|
return uint16(value)
|
|
}
|
|
|
|
// sendCurrentDungeonPotionRecovery emits one mode-1 frame per non-zero amount.
|
|
// The frame carries a single scalar, so a consumable that restores both HP and
|
|
// MP needs two.
|
|
func (s *Service) sendCurrentDungeonPotionRecovery(session *gameSession, objectKey uint16, hpAmount, mpAmount uint16) error {
|
|
for _, amount := range [...]uint16{hpAmount, mpAmount} {
|
|
if amount == 0 {
|
|
continue
|
|
}
|
|
if err := s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketNotifyDieMonster),
|
|
buildCurrentDungeonPotionRecoveryBody(objectKey, amount),
|
|
0,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildCurrentDungeonPotionRecoveryBody writes the mode-1 branch of the same
|
|
// class-0/op38 reader that buildCurrentDungeonDeathNotificationBody feeds with
|
|
// mode 0: target key, zero drops, no optional u16, mode, the 0xff
|
|
// "no progression update" sentinel and no optional room, then the mode-1
|
|
// payload of target key and scalar.
|
|
func buildCurrentDungeonPotionRecoveryBody(targetObjectKey uint16, amount uint16) []byte {
|
|
var writer packetWriter
|
|
writer.writeUint16(targetObjectKey)
|
|
writer.writeByte(0)
|
|
writer.writeByte(0)
|
|
writer.writeByte(1)
|
|
writer.writeByte(0xff)
|
|
writer.writeByte(0)
|
|
writer.writeUint16(targetObjectKey)
|
|
writer.writeUint16(amount)
|
|
return writer.bytes()
|
|
}
|
|
|
|
// parseCurrentDungeonUseSkillSkillID reads the skill number from the fixed
|
|
// 12-byte op38 body. The size is the same one the local debug strike hook
|
|
// already proves as currentLocalDebugUseSkillRequestSize; the number is
|
|
// big-endian in the first two bytes.
|
|
func parseCurrentDungeonUseSkillSkillID(body []byte) (uint16, bool) {
|
|
if len(body) != currentLocalDebugUseSkillRequestSize {
|
|
return 0, false
|
|
}
|
|
return uint16(body[0])<<8 | uint16(body[1]), true
|
|
}
|
|
|
|
func currentPotionWasteStackable(stackableType string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(stackableType, "`", "")))
|
|
return strings.HasPrefix(normalized, "[waste]")
|
|
}
|