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,154 @@
package itemdailylimit
import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// dailyUseContractFiles 是允许触碰「每天可用次数」(PVF `[total usable count]`)
// 计数与判定的**非测试**文件登记表。
//
// 为什么要这张表:这个契约此前被三套互不相干的实现分别解释(op44 一套通用预留、
// 疲劳药 op515 一套内联计数、塔券一套 allowance),于是 op515 的其余 action、
// op160/op27 开箱(66 件礼盒)和 op518 礼包一件都没被计过 —— 同一份 PVF 声明,
// 走不同的路就有不同的结果。守卫测试的作用就是让这种事无法再静默发生:
// 任何新文件只要引用计数键、判定函数或投影入口,就必须在这里登记并写明它为什么
// 算"一次使用"。改动时若确实新增了消费路径,请连同它的行为测试一起加。
var dailyUseContractFiles = map[string]string{
"internal/modules/dnf/fatigue/daily_reset.go": "06:00 服务日边界:与疲劳计数同界地把全部 item_daily_used_* 清零",
"internal/modules/dnf/itemdailylimit/limit.go": "唯一的计数/判定实现(Consume / Release / Used / Key)",
"internal/modules/dnf/alignedcmd/handler.go": "契约类型本身(StackableUseResolution.DailyUseLimit / Result.DailyUseStates)",
"internal/modules/dnf/inventory/owner.go": "op44:ReserveDailyItemUse / ReleaseDailyItemUse 委托本包",
"internal/modules/dnf/inventory/handler.go": "op44 与 op515 的预留在分派前完成,失败回滚",
"internal/modules/dnf/inventory/stackable_action_daily_use.go": "op515:统一预留 + op541 投影(action 58 让路给疲劳药事务)",
"internal/modules/dnf/inventory/fatigue_potion.go": "op515 action 58:计数必须与疲劳恢复同事务",
"internal/services/dnfbridge/stackable_use_pvf.go": "解析层:把 PVF [total usable count] 解析成 StackableUseResolution.DailyUseLimit",
"internal/services/dnfbridge/gameplay_module_booster.go": "op160/op27 开箱:同一事务内计数",
"internal/services/dnfbridge/cera_package.go": "op518 礼包:同一事务内计数",
"internal/services/dnfbridge/lottery_item.go": "op27 抽奖罐:计数在 commitCurrentBooster 内完成,op541 **刻意**不发(结果包后不得再有包)",
"internal/services/dnfbridge/item_daily_use_state.go": "op541 投影(登录回放 + 开箱/礼包的非 aligned 路径)",
"internal/services/dnfbridge/game_aligned.go": "aligned 通道命令后的 op541 投影",
"internal/services/dnfbridge/select_character_inventory_bootstrap.go": "选角后的 op541 回放(客户端计数是内存态,必须重放)",
"internal/services/dnfbridge/daily_fatigue_reset.go": "在线跨 06:00 的重置调度:读 PotionUsageDayStatKey 判断是否要 reconcile(清零 item_daily_used_* 的同一份边界)。注意:重置后**没有**重放 op541 —— 那是一条已知缺口,需单独评审(它会给 06:00 的包序加一个 op541,触碰 daily_fatigue_reset_test.go 钉死的『只发疲劳 HUD』断言)",
}
// dailyUseContractTokens 是"碰到了这份契约"的源码特征:计数键、判定函数、
// 投影入口。命中任意一个的非测试文件都必须出现在上面的登记表里。
//
// 用**裸**标识符而不是 `dnffatigue.X` 限定名:fatigue 包内部用的是不带包名的
// 常量名,只写限定名会让登记表里的 fatigue 条目被反向检查判成失效。
var dailyUseContractTokens = []string{
"ItemUsageStatPrefix",
"PotionUsageStatPrefix",
"ItemUsageDayStatKey",
"PotionUsageDayStatKey",
"dnfitemdailylimit.",
"dailyItemUseStatKey",
"dailyItemUseUsed",
"ReserveDailyItemUse",
"ReleaseDailyItemUse",
"DailyItemUseReservation",
"sendCurrentItemDailyUseState",
"sendAlignedDailyUseStates",
"sendCurrentCharacterDailyUseStates",
}
func TestDailyUseContractTouchesOnlyRegisteredRoutes(t *testing.T) {
root := dailyUseModuleRoot(t)
offenders := make([]string, 0)
walked := 0
err := filepath.WalkDir(filepath.Join(root, "internal"), func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
return nil
}
relative, relErr := filepath.Rel(root, path)
if relErr != nil {
return relErr
}
relative = filepath.ToSlash(relative)
data, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
walked++
text := string(data)
for _, token := range dailyUseContractTokens {
if !strings.Contains(text, token) {
continue
}
if _, registered := dailyUseContractFiles[relative]; !registered {
offenders = append(offenders, relative+"(命中 "+token+")")
}
break
}
return nil
})
if err != nil {
t.Fatalf("扫描源码失败: %v", err)
}
if walked == 0 {
t.Fatal("没有扫描到任何源码,守卫测试本身失效了")
}
sort.Strings(offenders)
if len(offenders) != 0 {
t.Fatalf("以下文件触碰了「每天可用次数」契约却没有登记。\n"+
"如果是新增的消费路径:它必须在使用道具的同一个事务里按道具自己的 PVF 上限计数,"+
"并在成功后投影 op541,然后把文件登记进 dailyUseContractFiles 并补一条行为测试;\n"+
"如果只是引用而并不消费:请在登记表里写明理由。\n未登记文件:%v", offenders)
}
// 反向检查:登记表不得腐烂成一张列着不存在/已不再触碰契约的文件的空表。
stale := make([]string, 0)
for relative := range dailyUseContractFiles {
path := filepath.Join(root, filepath.FromSlash(relative))
data, readErr := os.ReadFile(path)
if readErr != nil {
stale = append(stale, relative+"(文件不存在)")
continue
}
text := string(data)
hit := false
for _, token := range dailyUseContractTokens {
if strings.Contains(text, token) {
hit = true
break
}
}
if !hit {
stale = append(stale, relative+"(已不再触碰契约)")
}
}
sort.Strings(stale)
if len(stale) != 0 {
t.Fatalf("登记表里有失效条目,请更新 dailyUseContractFiles: %v", stale)
}
}
// dailyUseModuleRoot 从测试文件出发向上找到含 go.mod 的模块根目录。
func dailyUseModuleRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("向上找不到 go.mod")
}
dir = parent
}
}
@@ -0,0 +1,127 @@
// Package itemdailylimit owns the PVF `[total usable count]` contract -- the
// 「每天可用次数」 line the client prints in an item's tooltip.
//
// The declaration belongs to the *item*, not to the route that consumes it. The
// ordinary use request (op44), the action use request (op515), opening a booster
// box (op160/op27) and expanding a package all consume a consumable, and every
// one of them has to charge the same per-character daily counter. Keeping the
// implementation in one place is what makes that true: a route only hands over
// the item id and the declared limit, and never interprets the field itself.
//
// The counter is one durable stat row per item (`item_daily_used_<itemID>`,
// plus the legacy `fatigue_potion_used_<itemID>` spelling that stays readable so
// a counter written earlier the same service day is not lost). It shares the
// 06:00 Beijing service-day boundary of the fatigue counters, and it is
// projected to the client as op541, so the tooltip follows every use -- also for
// items whose use route the server does not otherwise touch.
package itemdailylimit
import (
"errors"
"fmt"
"strconv"
"time"
dnffatigue "longheng.io/server/internal/modules/dnf/fatigue"
)
// ErrLimitReached marks the "[total usable count] reached for today" refusal so
// callers can answer the ordinary failure without consuming the item.
var ErrLimitReached = errors.New("daily item use limit reached")
// LimitReachedError reports the numbers behind that refusal.
type LimitReachedError struct {
ItemID int64
Used int64
Limit int64
}
func (e LimitReachedError) Error() string {
return fmt.Sprintf("item=%d daily use limit reached: used=%d limit=%d", e.ItemID, e.Used, e.Limit)
}
func (e LimitReachedError) Is(target error) bool {
return target == ErrLimitReached
}
// Key is the durable stat key of one item's counter.
func Key(itemID int64) string {
return dnffatigue.ItemUsageStatPrefix + strconv.FormatInt(itemID, 10)
}
// LegacyKey is the fatigue-potion spelling the counter used before the generic
// key existed.
func LegacyKey(itemID int64) string {
return dnffatigue.PotionUsageStatPrefix + strconv.FormatInt(itemID, 10)
}
// DeclaresLimit reports whether a PVF limit caps the item. Zero (or negative)
// means the .stk carries no `[total usable count]`: no cap, and nothing is read
// or written for it.
func DeclaresLimit(limit int64) bool {
return limit > 0
}
// Used reads today's used count. An item present under both the generic and the
// legacy spelling keeps the larger value, so it can never be counted twice.
func Used(stats map[string]int64, itemID int64) int64 {
if stats == nil || itemID <= 0 {
return 0
}
used := stats[Key(itemID)]
if legacy := stats[LegacyKey(itemID)]; legacy > used {
used = legacy
}
if used < 0 {
return 0
}
return used
}
// Reconcile applies the shared 06:00 Beijing service-day boundary to stats.
//
// It delegates to the fatigue counters' own boundary on purpose: 「每天可用次数」
// has to return at the same moment the game tells the player to retry after 6
// ("请在6点以后重试"), so the two must never drift apart.
func Reconcile(stats map[string]int64, now time.Time, level int) {
dnffatigue.ReconcileDailyStats(stats, now, level)
}
// Consume charges one use of itemID against its declared limit and returns the
// new used count.
//
// limit<=0 or itemID<=0 means the PVF declares no cap: nothing is read or
// written, and used=0 is returned. The day boundary is applied first, so a use
// that happens to be the first of a new service day never sees yesterday's
// counter. Reaching the cap returns LimitReachedError (errors.Is
// ErrLimitReached) and leaves the counter untouched.
func Consume(stats map[string]int64, itemID int64, limit int64, now time.Time, level int) (int64, error) {
if stats == nil || itemID <= 0 || !DeclaresLimit(limit) {
return 0, nil
}
Reconcile(stats, now, level)
used := Used(stats, itemID)
if used >= limit {
return used, LimitReachedError{ItemID: itemID, Used: used, Limit: limit}
}
used++
stats[Key(itemID)] = used
return used, nil
}
// Release gives back one charged use when the action it was reserved for did not
// commit, so a rejected command never burns part of the player's daily
// allowance. It returns the restored count.
func Release(stats map[string]int64, itemID int64) int64 {
if stats == nil || itemID <= 0 {
return 0
}
key := Key(itemID)
used := stats[key]
if used <= 0 {
return 0
}
used--
stats[key] = used
return used
}
@@ -0,0 +1,119 @@
package itemdailylimit
import (
"errors"
"testing"
"time"
dnffatigue "longheng.io/server/internal/modules/dnf/fatigue"
"longheng.io/server/internal/modules/dnf/premium"
)
func TestConsumeWithoutDeclaredLimitTouchesNothing(t *testing.T) {
stats := map[string]int64{}
for _, limit := range []int64{0, -1} {
used, err := Consume(stats, 490003149, limit, time.Now().UTC(), 90)
if err != nil || used != 0 {
t.Fatalf("limit=%d used=%d err=%v", limit, used, err)
}
}
if len(stats) != 0 {
t.Fatalf("没有 [total usable count] 的道具不应该写入任何东西: %+v", stats)
}
}
func TestConsumeEnforcesTheDeclaredCap(t *testing.T) {
now := time.Date(2026, 9, 21, 4, 0, 0, 0, time.UTC)
stats := map[string]int64{dnffatigue.ItemUsageDayStatKey: premium.ServiceDayID(now)}
const itemID, limit = int64(10153501), int64(3)
for want := int64(1); want <= limit; want++ {
used, err := Consume(stats, itemID, limit, now, 90)
if err != nil || used != want {
t.Fatalf("第 %d 次 used=%d err=%v", want, used, err)
}
}
used, err := Consume(stats, itemID, limit, now, 90)
var reached LimitReachedError
if !errors.Is(err, ErrLimitReached) || !errors.As(err, &reached) {
t.Fatalf("超过上限应返回可识别的拒绝: %v", err)
}
if reached.ItemID != itemID || reached.Used != limit || reached.Limit != limit {
t.Fatalf("拒绝里带的数字不对: %+v", reached)
}
if used != limit || stats[Key(itemID)] != limit {
t.Fatalf("被拒的一次不得改动计数: used=%d stats=%+v", used, stats)
}
}
func TestConsumeAppliesTheServiceDayBoundary(t *testing.T) {
now := time.Date(2026, 9, 21, 4, 0, 0, 0, time.UTC)
const itemID, limit = int64(490003149), int64(2)
stats := map[string]int64{
// 昨天的计数已经打满,今天必须从 0 开始。
dnffatigue.ItemUsageDayStatKey: premium.ServiceDayID(now.Add(-24 * time.Hour)),
Key(itemID): limit,
Key(490003150): 5,
}
used, err := Consume(stats, itemID, limit, now, 90)
if err != nil || used != 1 {
t.Fatalf("跨 06:00 后第一次应该从 0 开始: used=%d err=%v stats=%+v", used, err, stats)
}
if stats[Key(490003150)] != 0 {
t.Fatalf("同一天边界必须清掉其它道具的计数: %+v", stats)
}
if stats[dnffatigue.ItemUsageDayStatKey] != premium.ServiceDayID(now) {
t.Fatalf("服务日没有推进: %+v", stats)
}
}
func TestConsumeReadsTheLegacyFatiguePotionSpelling(t *testing.T) {
now := time.Date(2026, 9, 21, 4, 0, 0, 0, time.UTC)
const itemID, limit = int64(490702338), int64(3)
stats := map[string]int64{
dnffatigue.ItemUsageDayStatKey: premium.ServiceDayID(now),
// 旧键属于疲劳药那一天的服务日,两个日键都要在,否则 ReconcileDailyStats
// 会先把它当成"没初始化过的一天"清掉(既有语义,不是本模块引入的)。
dnffatigue.PotionUsageDayStatKey: premium.ServiceDayID(now),
// 通用键出现之前写下的旧计数不能被当成 0。
LegacyKey(itemID): 2,
}
if got := Used(stats, itemID); got != 2 {
t.Fatalf("旧键读数=%d want 2", got)
}
used, err := Consume(stats, itemID, limit, now, 90)
if err != nil || used != 3 {
t.Fatalf("应从旧计数继续: used=%d err=%v", used, err)
}
if _, err := Consume(stats, itemID, limit, now, 90); !errors.Is(err, ErrLimitReached) {
t.Fatalf("旧键 + 新键合计到上限后必须拒绝: %v", err)
}
}
func TestReleaseGivesBackASingleReservedUse(t *testing.T) {
now := time.Date(2026, 9, 21, 4, 0, 0, 0, time.UTC)
const itemID, limit = int64(490003149), int64(2)
stats := map[string]int64{dnffatigue.ItemUsageDayStatKey: premium.ServiceDayID(now)}
if _, err := Consume(stats, itemID, limit, now, 90); err != nil {
t.Fatal(err)
}
if got := Release(stats, itemID); got != 0 {
t.Fatalf("回滚后计数=%d want 0", got)
}
// 没有可回滚的次数时不得把它做成负数。
if got := Release(stats, itemID); got != 0 || stats[Key(itemID)] != 0 {
t.Fatalf("重复回滚破坏了计数: got=%d stats=%+v", got, stats)
}
}
func TestUsedToleratesHostileStats(t *testing.T) {
if got := Used(nil, 1001); got != 0 {
t.Fatalf("nil stats=%d", got)
}
if got := Used(map[string]int64{Key(1001): -5}, 1001); got != 0 {
t.Fatalf("负数计数=%d", got)
}
if got := Used(map[string]int64{}, 0); got != 0 {
t.Fatalf("零道具 id=%d", got)
}
}