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