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,186 @@
package dnfbridge
import (
"context"
"strconv"
"time"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// PVF `[daily delete item] 1` —— 每日重置时删除的物品。
//
// # 为什么不能复用 expire_at
//
// 本服**有意屏蔽了物品期限**:cmd/scratch/pvfremoveitemexpiry 从运行时 PVF 摘掉
// `[expiration date]` / `[usable datetime]` / `[usable period]` /
// `[usable period after unsealing]`,cmd/scratch/removeitemexpirydb 清掉库里的
// 期限字段。所以 expire_at 这条通道是被主动关掉的,往它上面盖戳会在下一次跑那两个
// 工具时被一起清掉。
//
// `[daily delete item]` **不在那两个工具的剥离名单里**,在运行时 PVF 里仍然有效 ——
// 客户端把它渲染成"期限 1 天",而服务端此前完全没解析过这个字段(全仓库 grep
// "daily delete" 零命中),于是这类物品永远不会消失。玩家实测:公会祝福礼盒
// (490001382)与魔界抗疲劳秘药(490001383)显示 1 天期限却一直留在背包里。
//
// 所以这里用**独立的状态位**,不碰 expire_at:把物品被发放(或首次被看见)的服务日
// 记在 stack.Extra 里,跨过服务日边界就删。这样期限屏蔽照旧、每日删除照常生效,
// 两者互不影响。
//
// # 边界
//
// 服务日边界是北京 06:00,复用 premium.ServiceDayID(它把 unix 秒 +2h 再按 24h 分桶,
// 因为 06:00 UTC+8 就是前一civil日的 22:00 UTC)。这和库里现有期限值全都落在
// `22:00:00` 是同一个边界。
//
// # 存量
//
// 已经在背包里、没有戳的旧物品**不当场没收**(运营决定 2026-09-16):扫描时补盖成
// 当天,于是它们在下一个 06:00 消失。
const (
// currentDailyDeleteItemStateKey 存放该物品实例被发放/首次看见的服务日 ID。
currentDailyDeleteItemStateKey = "daily_delete_day"
)
// currentDailyDeleteServiceDay 是当前服务日 ID。
func (s *Service) currentDailyDeleteServiceDay(now time.Time) int64 {
if now.IsZero() {
now = s.gameplayNow()
}
return premium.ServiceDayID(now.UTC())
}
// stampCurrentDailyDeleteItem 在发放时给每日删除物品打上当前服务日。
// 非每日删除物品原样返回,调用方无需先判断。
func stampCurrentDailyDeleteItem(
stack dnfrepo.ItemStack,
definition dungeonDropItemDefinition,
serviceDay int64,
) (dnfrepo.ItemStack, bool) {
if !definition.DailyDeleteItem {
return stack, false
}
value := strconv.FormatInt(serviceDay, 10)
if stack.Extra != nil && stack.Extra[currentDailyDeleteItemStateKey] == value {
return stack, false
}
if stack.Extra == nil {
stack.Extra = make(map[string]string, 1)
}
stack.Extra[currentDailyDeleteItemStateKey] = value
return stack, true
}
// currentDailyDeleteItemDue 判断一个已打戳的物品是不是该删了。
// 没有戳的返回 (false, false):调用方应当补盖,而不是当场删除。
func currentDailyDeleteItemDue(stack dnfrepo.ItemStack, serviceDay int64) (due bool, stamped bool) {
raw, ok := stack.Extra[currentDailyDeleteItemStateKey]
if !ok {
return false, false
}
stored, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
// 戳坏了就当没有:补盖一次,下一个边界再删。总比凭一个读不懂的值没收东西好。
return false, false
}
return serviceDay > stored, true
}
// sweepSelectedDailyDeleteItems 在选人/背包 bootstrap 时跑一次:跨过服务日边界的
// 每日删除物品就地删掉,没有戳的补盖成当天。
//
// 只解析**背包里实际存在的** item id 的定义(几十个),不扫全 PVF —— 公会合成那次
// 全量 ResolveItem 让第一次操作卡了 8.66 秒,这里不重蹈覆辙。
func (s *Service) sweepSelectedDailyDeleteItems(session *gameSession, source string) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return
}
items, err := s.currentPVFItemCatalog()
if err != nil {
return
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterAssets == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
characterID := strconv.Itoa(int(session.selectedCharacterID))
serviceDay := s.currentDailyDeleteServiceDay(s.gameplayNow())
now := s.gameplayNow().UTC()
var removed, stamped []string
err = repositories.CharacterAssets.WithinCharacterAssets(
ctx,
characterID,
func(
_ dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
record, found, loadErr := inventories.Load(ctx, characterID)
if loadErr != nil || !found {
return loadErr
}
record = dnfrepo.CloneInventory(record)
if len(record.Slots) == 0 {
return nil
}
// item id -> 是否每日删除,按背包里出现过的 id 缓存,避免同一个 id
// 在多个格子里重复解析。
daily := make(map[int64]bool, 16)
changed := false
for key, stack := range record.Slots {
if stack.ItemID <= 0 || stack.ItemID > int64(^uint32(0)) {
continue
}
isDaily, seen := daily[stack.ItemID]
if !seen {
definition, defErr := items.ResolveItem(uint32(stack.ItemID))
isDaily = defErr == nil && definition.DailyDeleteItem
daily[stack.ItemID] = isDaily
}
if !isDaily {
continue
}
due, hasStamp := currentDailyDeleteItemDue(stack, serviceDay)
switch {
case due:
delete(record.Slots, key)
removed = append(removed, key)
changed = true
case !hasStamp:
// 存量补盖:不当场没收,下一个服务日边界才消失。
updated, dirty := stampCurrentDailyDeleteItem(stack, dungeonDropItemDefinition{DailyDeleteItem: true}, serviceDay)
if dirty {
record.Slots[key] = updated
stamped = append(stamped, key)
changed = true
}
}
}
if !changed {
return nil
}
record.UpdatedAt = now
return dnfrepo.SaveInventoryFields(ctx, inventories, record, dnfrepo.InventoryFieldSlots)
},
)
if err != nil {
s.logGameEvent(session, "game-daily-delete-item-sweep-failed",
"source", source, "char_id", session.selectedCharacterID, "reason", err)
return
}
if len(removed) == 0 && len(stamped) == 0 {
return
}
s.logGameEvent(session, "game-daily-delete-item-sweep-applied",
"source", source,
"char_id", session.selectedCharacterID,
"service_day", serviceDay,
"removed", len(removed),
"removed_keys", removed,
"stamped", len(stamped),
"stamped_keys", stamped)
}