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,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)
|
||||
}
|
||||
Reference in new issue
Block a user