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,273 @@
package dnfbridge
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrental "longheng.io/server/internal/modules/dnf/rental"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentRentalPointMetadataKey = dnfrental.PointMetadataKey
currentRentalItemSource = "pvf_rental"
currentRentalDuration = 24 * time.Hour
)
var (
errCurrentRentalOwnerMismatch = dnfrental.ErrOwnerMismatch
errCurrentRentalStateMissing = dnfrental.ErrStateMissing
errCurrentRentalPoints = dnfrental.ErrPointsInsufficient
errCurrentRentalGold = dnfrental.ErrGoldInsufficient
errCurrentRentalLimit = dnfrental.ErrPointLimit
errCurrentRentalInventoryFull = errors.New("dnf rental equipment inventory is full")
)
type currentRentalAssetResult struct {
Points uint32
Gold int64
ExpireAt time.Time
Slot int16
Equipped bool
}
// currentRentalItemAttributes are the per-tier item attributes the rental
// catalog declares and that every granted row has to carry. Both are written
// into the durable stack's Extra, so they survive unequip/re-equip and the
// repository round trip instead of living only on the row we happen to send.
type currentRentalItemAttributes struct {
Reinforce int64
Forge int64
}
func cleanupExpiredCurrentRentalEquipment(
ctx context.Context,
owner *dnfrental.Owner,
accountID string,
characterID string,
now time.Time,
) (int, error) {
if owner == nil {
return 0, dnfrepo.ErrRentalAssetTransactionUnavailable
}
removed := 0
err := owner.Cleanup(ctx, dnfrental.CleanupCommand{
AccountID: accountID,
CharacterID: characterID,
UpdatedAt: now,
Project: func(assets *dnfrental.Assets) (dnfrental.Changes, error) {
inventoryDirty := false
for key, stack := range assets.Inventory.Slots {
if !currentRentalItemExpired(stack.Extra, stack.ExpireAt, now) {
continue
}
delete(assets.Inventory.Slots, key)
inventoryDirty = true
removed++
}
equipmentDirty := false
for key, entry := range assets.Equipment.Entries {
if !currentRentalItemExpired(entry.Extra, entry.ExpireAt, now) {
continue
}
delete(assets.Equipment.Entries, key)
equipmentDirty = true
removed++
}
return dnfrental.Changes{Inventory: inventoryDirty, Equipment: equipmentDirty}, nil
},
})
if err != nil {
return 0, currentRentalMutationError(err)
}
return removed, nil
}
func purchaseCurrentRentalPoints(
ctx context.Context,
owner *dnfrental.Owner,
accountID string,
characterID string,
count uint32,
limit uint32,
goldPerPoint int64,
now time.Time,
) (currentRentalAssetResult, error) {
if owner == nil {
return currentRentalAssetResult{}, dnfrepo.ErrRentalAssetTransactionUnavailable
}
result, err := owner.Charge(ctx, dnfrental.ChargeCommand{
AccountID: accountID,
CharacterID: characterID,
Count: count,
Limit: limit,
GoldPerPoint: goldPerPoint,
UpdatedAt: now,
})
if err != nil {
return currentRentalAssetResult{}, currentRentalMutationError(err)
}
return currentRentalAssetResult{Points: result.Points, Gold: result.Gold}, nil
}
func rentCurrentEquipment(
ctx context.Context,
owner *dnfrental.Owner,
accountID string,
characterID string,
itemID uint32,
pointCost uint32,
definition dungeonDropItemDefinition,
attributes currentRentalItemAttributes,
now time.Time,
) (currentRentalAssetResult, error) {
if owner == nil || itemID == 0 || pointCost == 0 || definition.ItemID != itemID ||
definition.Kind != dungeonDropItemEquipment || definition.Durability == 0 {
return currentRentalAssetResult{}, dnfrepo.ErrRentalAssetTransactionUnavailable
}
expireAt := now.UTC().Add(currentRentalDuration)
result := currentRentalAssetResult{ExpireAt: expireAt}
wallet, err := owner.Rent(ctx, dnfrental.RentCommand{
AccountID: accountID,
CharacterID: characterID,
PointCost: pointCost,
UpdatedAt: now,
Project: func(assets *dnfrental.Assets) (dnfrental.Changes, error) {
inventory := assets.Inventory
equipment := assets.Equipment
inventoryDirty, equipmentDirty := false, false
foundExisting := false
for key, stack := range inventory.Slots {
listType, slot, ok := parseSceneInventorySlotKey(key)
if !ok || listType != 0 || slot < definition.SlotStart || slot > definition.SlotEnd ||
stack.ItemID != int64(itemID) ||
!strings.EqualFold(strings.TrimSpace(stack.Extra["source"]), currentRentalItemSource) {
continue
}
stack = currentRentalStack(stack, itemID, definition, attributes, expireAt)
inventory.Slots[key] = stack
inventoryDirty = true
foundExisting = true
result.Slot = slot
}
for key, entry := range equipment.Entries {
if entry.ItemID != int64(itemID) ||
!strings.EqualFold(strings.TrimSpace(entry.Extra["source"]), currentRentalItemSource) {
continue
}
entry.ExpireAt = expireAt
entry.Extra = currentRentalExtra(entry.Extra, definition, attributes, expireAt)
equipment.Entries[key] = entry
equipmentDirty = true
foundExisting = true
result.Slot = entry.SlotIndex
result.Equipped = true
}
if !foundExisting {
slot, ok := firstCurrentRentalInventorySlot(inventory.Slots, definition.SlotStart, definition.SlotEnd)
if !ok {
return dnfrental.Changes{}, errCurrentRentalInventoryFull
}
stack := currentRentalStack(dnfrepo.ItemStack{}, itemID, definition, attributes, expireAt)
inventory.Slots[fmt.Sprintf("0:%d", slot)] = stack
inventoryDirty = true
result.Slot = slot
}
return dnfrental.Changes{Inventory: inventoryDirty, Equipment: equipmentDirty}, nil
},
})
if err != nil {
return currentRentalAssetResult{}, currentRentalMutationError(err)
}
result.Points = wallet.Points
result.Gold = wallet.Gold
return result, nil
}
func currentRentalPoints(account dnfrepo.AccountRecord) (uint32, error) {
return dnfrental.Points(account)
}
func currentRentalStack(stack dnfrepo.ItemStack, itemID uint32, definition dungeonDropItemDefinition, attributes currentRentalItemAttributes, expireAt time.Time) dnfrepo.ItemStack {
stack.ItemID = int64(itemID)
stack.Count = 1
stack.Bind = false
stack.ExpireAt = expireAt
stack.Extra = currentRentalExtra(stack.Extra, definition, attributes, expireAt)
entry := currentItemListEntryFromStack(0, 0, stack)
// 续租是拿上一行的 RawEntry 当底再重投影的,而 setByte 对 0 是 no-op(那是
// 有意为之的全局规则:让服务端不拥有的字节透传)。结果是本档把锻造降成 0 时,
// 上一行的旧锻造字节会原样留下。租赁行的 +0x0A 与 +0x57 完全由目录拥有
// (PVF 把 `upgrade`/`separate upgrade` 列为不可操作),所以在这里显式写死,
// 只影响租赁行,不动共享的 setByte 语义。
entry.data[0x0A] = byte(attributes.Reinforce)
entry.data[currentItemListForgingLevelOffset] = byte(attributes.Forge)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
return stack
}
func currentRentalExtra(extra map[string]string, definition dungeonDropItemDefinition, attributes currentRentalItemAttributes, expireAt time.Time) map[string]string {
out := make(map[string]string, len(extra)+10)
for key, value := range extra {
out[key] = value
}
out["source"] = currentRentalItemSource
out["item_kind"] = string(dungeonDropItemEquipment)
out["pvf_path"] = definition.PVFPath
out["durability"] = strconv.FormatUint(uint64(definition.Durability), 10)
out["max_durability"] = strconv.FormatUint(uint64(definition.Durability), 10)
out["expire_time"] = strconv.FormatInt(expireAt.Unix(), 10)
out["rental_duration_hours"] = "24"
// 强化/锻造只能在这里写入:租赁武器自己的 PVF 把 `upgrade` 与
// `separate upgrade` 都列进 `[impossible contents]`,玩家没有任何途径事后补上,
// 所以发放行一旦漏掉,这把武器就永远是 +0。
//
// 写入的键沿用仓库既有约定(gm_admin.go / gm_tool.go 同样成对写):
// ext_data0 —— 背包行 +0x0A 与穿戴行 +0x0A 都读它,只写它是够的;
// reinforce —— 同一等级的规范别名,穿戴侧读取顺序里排在最后;
// forging_level —— +0x57 锻造字节,背包行与穿戴行同键。
// 三个键的取值都是十进制等级本身,没有 <<1 之类的打包(那是 mode0 角色外观
// 包 sub_20026C0 的另一种编码,不要混用)。
reinforce := strconv.FormatInt(attributes.Reinforce, 10)
out["reinforce"] = reinforce
out["ext_data0"] = reinforce
out["forging_level"] = strconv.FormatInt(attributes.Forge, 10)
// 品级:不写这个键,列表行投影就解析不出种子(itemquality.StackSeed 返回 0),
// 于是 +0x06 落到数量回退值 1,客户端按种子 1 重画成「最下级」——正是现场看到的
// 「预览最上级 90%、到手最下级 13%」。这是背包行与穿戴行唯一的品级来源键,
// 写在这一处即可覆盖首租/续租/穿戴续租三条路径。
out["quality_seed"] = strconv.FormatUint(uint64(currentRentalQualitySeed), 10)
return out
}
func firstCurrentRentalInventorySlot(slots map[string]dnfrepo.ItemStack, first, last int16) (int16, bool) {
occupied := make(map[int16]struct{}, len(slots))
for key := range slots {
listType, slot, ok := parseSceneInventorySlotKey(key)
if ok && listType == 0 {
occupied[slot] = struct{}{}
}
}
for slot := first; slot <= last; slot++ {
if _, used := occupied[slot]; !used {
return slot, true
}
}
return 0, false
}
func currentRentalItemExpired(extra map[string]string, expireAt time.Time, now time.Time) bool {
if !strings.EqualFold(strings.TrimSpace(extra["source"]), currentRentalItemSource) {
return false
}
if raw := strings.TrimSpace(extra["expire_time"]); raw != "" {
if unix, err := strconv.ParseInt(raw, 10, 64); err == nil && unix > 0 {
return unix <= now.Unix()
}
}
return !expireAt.IsZero() && !expireAt.After(now)
}