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,112 @@
|
||||
package fatigue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/premium"
|
||||
)
|
||||
|
||||
const (
|
||||
DailyRecoveryDayStatKey = "fatigue_recovery_day_id"
|
||||
FatigueDailyDateStatKey = "fatigue_daily_date"
|
||||
FatigueCreatedDayStatKey = "fatigue_created_day_id"
|
||||
PotionUsageDayStatKey = "fatigue_potion_day_id"
|
||||
PotionUsageStatPrefix = "fatigue_potion_used_"
|
||||
// ItemUsageDayStatKey / ItemUsageStatPrefix are the generic 每天可用次数
|
||||
// owner: one durable counter per item whose PVF definition declares
|
||||
// [total usable count]. They share the same 06:00 Beijing service-day
|
||||
// boundary as the fatigue counters, so a consumable's daily allowance
|
||||
// returns with the same reset the client advertises ("请在6点以后重试").
|
||||
ItemUsageDayStatKey = "item_daily_day_id"
|
||||
ItemUsageStatPrefix = "item_daily_used_"
|
||||
)
|
||||
|
||||
type DailyStatsResult struct {
|
||||
ServiceDayID int64
|
||||
Changed bool
|
||||
FatigueRestored bool
|
||||
PotionUsageReset bool
|
||||
ItemUsageReset bool
|
||||
}
|
||||
|
||||
// ReconcileDailyStats applies the shared 06:00 Beijing service-day boundary.
|
||||
// The caller owns persistence and must provide a non-nil stats map.
|
||||
func ReconcileDailyStats(stats map[string]int64, now time.Time, levels ...int) DailyStatsResult {
|
||||
result := DailyStatsResult{ServiceDayID: premium.ServiceDayID(now)}
|
||||
if stats == nil {
|
||||
return result
|
||||
}
|
||||
level := 0
|
||||
if len(levels) > 0 {
|
||||
level = levels[0]
|
||||
}
|
||||
batteryCapacity := BatteryCapacity(level)
|
||||
battery := min(max(stats[FatigueBatteryStatKey], 0), batteryCapacity)
|
||||
charge := min(max(stats[FatigueBatteryChargeStatKey], 0), FatigueBatteryChargeLimit)
|
||||
storedDay, dayInitialized := stats[DailyRecoveryDayStatKey]
|
||||
if dailyDate, ok := stats[FatigueDailyDateStatKey]; ok {
|
||||
storedDay, dayInitialized = dailyDate, true
|
||||
}
|
||||
if !dayInitialized || storedDay != result.ServiceDayID {
|
||||
previousFatigue, previousKnown := remainingStat(stats)
|
||||
limit := stats["fatigue_limit"]
|
||||
if limit <= 0 {
|
||||
limit = DefaultLimit
|
||||
if previousKnown && previousFatigue > limit {
|
||||
limit = previousFatigue
|
||||
}
|
||||
stats["fatigue_limit"] = limit
|
||||
}
|
||||
createdOnStoredDay := stats[FatigueCreatedDayStatKey] != 0 &&
|
||||
stats[FatigueCreatedDayStatKey] == storedDay
|
||||
if dayInitialized && storedDay == result.ServiceDayID-1 && previousKnown && !createdOnStoredDay {
|
||||
charge += min(max(previousFatigue, 0), FatigueBatteryChargeLimit-charge)
|
||||
}
|
||||
stats["fatigue"] = limit
|
||||
stats["fatigue_display_update"] = 0
|
||||
stats[DailyRecoveryDayStatKey] = result.ServiceDayID
|
||||
stats[FatigueDailyDateStatKey] = result.ServiceDayID
|
||||
result.Changed = true
|
||||
result.FatigueRestored = true
|
||||
}
|
||||
// Keep raw unused fatigue separate from already converted room charges.
|
||||
// Existing fatigue_battery values remain usable after upgrading.
|
||||
if charge >= FatigueBatteryActivation {
|
||||
converted := min(charge/FatigueBatteryPerBuff, batteryCapacity-battery)
|
||||
battery += converted
|
||||
charge -= converted * FatigueBatteryPerBuff
|
||||
}
|
||||
if stats[FatigueBatteryStatKey] != battery {
|
||||
stats[FatigueBatteryStatKey] = battery
|
||||
result.Changed = true
|
||||
}
|
||||
if stats[FatigueBatteryChargeStatKey] != charge {
|
||||
stats[FatigueBatteryChargeStatKey] = charge
|
||||
result.Changed = true
|
||||
}
|
||||
if stats[PotionUsageDayStatKey] != result.ServiceDayID {
|
||||
for key := range stats {
|
||||
if strings.HasPrefix(key, PotionUsageStatPrefix) {
|
||||
stats[key] = 0
|
||||
}
|
||||
}
|
||||
stats[PotionUsageDayStatKey] = result.ServiceDayID
|
||||
result.Changed = true
|
||||
result.PotionUsageReset = true
|
||||
}
|
||||
// The generic 每天可用次数 counters of every [total usable count] item move
|
||||
// with the same boundary; the client only ever learns the used byte from
|
||||
// op541, so a stale counter would silently swallow today's allowance.
|
||||
if stats[ItemUsageDayStatKey] != result.ServiceDayID {
|
||||
for key := range stats {
|
||||
if strings.HasPrefix(key, ItemUsageStatPrefix) {
|
||||
stats[key] = 0
|
||||
}
|
||||
}
|
||||
stats[ItemUsageDayStatKey] = result.ServiceDayID
|
||||
result.Changed = true
|
||||
result.ItemUsageReset = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package fatigue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/premium"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestReconcileDailyStatsCrossesBeijingSixAndResetsEveryPotion(t *testing.T) {
|
||||
before := time.Date(2026, 8, 30, 21, 59, 0, 0, time.UTC)
|
||||
after := time.Date(2026, 8, 30, 22, 1, 0, 0, time.UTC)
|
||||
stats := map[string]int64{
|
||||
"fatigue": 0, "fatigue_limit": BlackDiamondLimit, "fatigue_display_update": BlackDiamondLimit,
|
||||
DailyRecoveryDayStatKey: premium.ServiceDayID(before),
|
||||
PotionUsageDayStatKey: premium.ServiceDayID(before),
|
||||
PotionUsageStatPrefix + "490702338": 3,
|
||||
PotionUsageStatPrefix + "490702339": 2,
|
||||
// The generic 每天可用次数 counters of [total usable count] items move
|
||||
// with the same boundary, so they must be reset by this call too.
|
||||
ItemUsageDayStatKey: premium.ServiceDayID(before),
|
||||
ItemUsageStatPrefix + "490006906": 3,
|
||||
ItemUsageStatPrefix + "10153501": 1,
|
||||
}
|
||||
result := ReconcileDailyStats(stats, after)
|
||||
if !result.Changed || !result.FatigueRestored || !result.PotionUsageReset || !result.ItemUsageReset {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
if stats["fatigue"] != BlackDiamondLimit || stats["fatigue_limit"] != BlackDiamondLimit ||
|
||||
stats["fatigue_display_update"] != 0 ||
|
||||
stats[PotionUsageStatPrefix+"490702338"] != 0 || stats[PotionUsageStatPrefix+"490702339"] != 0 ||
|
||||
stats[ItemUsageStatPrefix+"490006906"] != 0 || stats[ItemUsageStatPrefix+"10153501"] != 0 {
|
||||
t.Fatalf("stats=%+v", stats)
|
||||
}
|
||||
wantDay := premium.ServiceDayID(after)
|
||||
if stats[DailyRecoveryDayStatKey] != wantDay || stats[PotionUsageDayStatKey] != wantDay ||
|
||||
stats[ItemUsageDayStatKey] != wantDay {
|
||||
t.Fatalf("service days recovery=%d potion=%d item=%d want=%d",
|
||||
stats[DailyRecoveryDayStatKey], stats[PotionUsageDayStatKey], stats[ItemUsageDayStatKey], wantDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDailyStatsDoesNotRepeatWithinServiceDay(t *testing.T) {
|
||||
now := time.Date(2026, 8, 30, 23, 0, 0, 0, time.UTC)
|
||||
dayID := premium.ServiceDayID(now)
|
||||
stats := map[string]int64{
|
||||
"fatigue": 77, "fatigue_limit": BlackDiamondLimit, "fatigue_display_update": 111,
|
||||
DailyRecoveryDayStatKey: dayID,
|
||||
PotionUsageDayStatKey: dayID,
|
||||
PotionUsageStatPrefix + "490702338": 2,
|
||||
ItemUsageDayStatKey: dayID,
|
||||
ItemUsageStatPrefix + "490006906": 2,
|
||||
}
|
||||
result := ReconcileDailyStats(stats, now.Add(2*time.Hour))
|
||||
if result.Changed || result.FatigueRestored || result.PotionUsageReset || result.ItemUsageReset {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
if stats["fatigue"] != 77 || stats["fatigue_display_update"] != 111 ||
|
||||
stats[PotionUsageStatPrefix+"490702338"] != 2 || stats[ItemUsageStatPrefix+"490006906"] != 2 {
|
||||
t.Fatalf("stats=%+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDailyStatsUsesDefaultLimitWhenMissing(t *testing.T) {
|
||||
now := time.Date(2026, 8, 30, 22, 1, 0, 0, time.UTC)
|
||||
stats := map[string]int64{"fatigue": 0}
|
||||
result := ReconcileDailyStats(stats, now)
|
||||
if !result.FatigueRestored || stats["fatigue"] != DefaultLimit || stats["fatigue_limit"] != DefaultLimit {
|
||||
t.Fatalf("result=%+v stats=%+v", result, stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatteryCapacityMatchesServerLevelBands(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
level int
|
||||
want int64
|
||||
}{
|
||||
{1, 78}, {19, 78}, {20, 117}, {29, 117}, {30, 156}, {39, 156}, {40, 234}, {90, 234},
|
||||
} {
|
||||
if got := BatteryCapacity(test.level); got != test.want {
|
||||
t.Fatalf("BatteryCapacity(%d)=%d, want %d", test.level, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDailyStatsStoresPreviousServiceDayFatigueInBattery(t *testing.T) {
|
||||
before := time.Date(2026, 8, 30, 21, 59, 0, 0, time.UTC)
|
||||
after := time.Date(2026, 8, 30, 22, 1, 0, 0, time.UTC)
|
||||
stats := map[string]int64{
|
||||
"fatigue": 150, "fatigue_limit": 188,
|
||||
DailyRecoveryDayStatKey: premium.ServiceDayID(before),
|
||||
}
|
||||
result := ReconcileDailyStats(stats, after, 90)
|
||||
if !result.Changed || stats["fatigue"] != 188 || stats[FatigueBatteryChargeStatKey] != 150 || stats[FatigueBatteryStatKey] != 0 {
|
||||
t.Fatalf("result=%+v stats=%+v", result, stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDailyStatsPreservesBatteryAfterSkippedServiceDay(t *testing.T) {
|
||||
now := time.Date(2026, 8, 30, 22, 1, 0, 0, time.UTC)
|
||||
stats := map[string]int64{
|
||||
"fatigue": 80, "fatigue_limit": 156, FatigueBatteryStatKey: 20,
|
||||
DailyRecoveryDayStatKey: premium.ServiceDayID(now.Add(-48 * time.Hour)),
|
||||
}
|
||||
ReconcileDailyStats(stats, now, 30)
|
||||
if stats[FatigueBatteryStatKey] != 20 || stats[FatigueBatteryChargeStatKey] != 0 {
|
||||
t.Fatalf("stats=%+v, want existing battery retained without invented skipped-day fatigue", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDailyStatsDoesNotChargeCreationServiceDay(t *testing.T) {
|
||||
createdDay := time.Date(2026, 8, 29, 22, 1, 0, 0, time.UTC)
|
||||
nextDay := createdDay.Add(24 * time.Hour)
|
||||
stats := map[string]int64{
|
||||
"fatigue": 156, "fatigue_limit": 156,
|
||||
DailyRecoveryDayStatKey: premium.ServiceDayID(createdDay),
|
||||
FatigueCreatedDayStatKey: premium.ServiceDayID(createdDay),
|
||||
FatigueBatteryStatKey: 0,
|
||||
}
|
||||
ReconcileDailyStats(stats, nextDay, 1)
|
||||
if stats[FatigueBatteryStatKey] != 0 {
|
||||
t.Fatalf("battery=%d, want 0 on first day after creation", stats[FatigueBatteryStatKey])
|
||||
}
|
||||
ReconcileDailyStats(stats, nextDay.Add(24*time.Hour), 1)
|
||||
if stats[FatigueBatteryStatKey] != 31 || stats[FatigueBatteryChargeStatKey] != 1 {
|
||||
t.Fatalf("stats=%+v, want 31 charges and 1 raw point after a complete service day", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileDailyStatsAccumulatesAndConvertsBattery(t *testing.T) {
|
||||
now := time.Date(2026, 9, 5, 0, 0, 0, 0, time.UTC)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
level int
|
||||
stored, charge, unused, wantStored, wantCharge int64
|
||||
}{
|
||||
{"below threshold", 40, 0, 100, 55, 0, 155},
|
||||
{"at threshold", 40, 0, 100, 56, 31, 1},
|
||||
{"retains existing blessing", 40, 20, 100, 56, 51, 1},
|
||||
{"low level cap", 19, 70, 0, 156, 78, 116},
|
||||
{"full cap retains raw charge", 40, 234, 100, 100, 234, 200},
|
||||
{"raw storage cap", 40, 234, 19999, 188, 234, 20000},
|
||||
{"negative values", 40, -1, -1, -1, 0, 0},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stats := map[string]int64{
|
||||
"fatigue": test.unused, "fatigue_limit": 188,
|
||||
FatigueBatteryStatKey: test.stored, FatigueBatteryChargeStatKey: test.charge,
|
||||
DailyRecoveryDayStatKey: premium.ServiceDayID(now) - 1,
|
||||
}
|
||||
ReconcileDailyStats(stats, now, test.level)
|
||||
if stats[FatigueBatteryStatKey] != test.wantStored || stats[FatigueBatteryChargeStatKey] != test.wantCharge {
|
||||
t.Fatalf("stats=%+v, want battery=%d charge=%d", stats, test.wantStored, test.wantCharge)
|
||||
}
|
||||
if result := ReconcileDailyStats(stats, now.Add(time.Hour), test.level); result.Changed {
|
||||
t.Fatalf("same day changed again: %+v", stats)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerReconcileDailyPersistsOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
before := time.Date(2026, 8, 30, 21, 59, 0, 0, time.UTC)
|
||||
after := time.Date(2026, 8, 30, 22, 1, 0, 0, time.UTC)
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "19", AccountID: "account-1", Stats: map[string]int64{
|
||||
"fatigue": 0, "fatigue_limit": DefaultLimit,
|
||||
DailyRecoveryDayStatKey: premium.ServiceDayID(before),
|
||||
PotionUsageDayStatKey: premium.ServiceDayID(before),
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := owner.ReconcileDaily(ctx, ReconcileDailyCommand{
|
||||
AccountID: "account-1", CharacterID: "19", UpdatedAt: after,
|
||||
})
|
||||
if err != nil || !first.Changed || first.Character.Stats["fatigue"] != DefaultLimit || !first.Character.UpdatedAt.Equal(after) {
|
||||
t.Fatalf("first=%+v err=%v", first, err)
|
||||
}
|
||||
second, err := owner.ReconcileDaily(ctx, ReconcileDailyCommand{
|
||||
AccountID: "account-1", CharacterID: "19", UpdatedAt: after.Add(time.Hour),
|
||||
})
|
||||
if err != nil || second.Changed || !second.Character.UpdatedAt.Equal(after) {
|
||||
t.Fatalf("second=%+v err=%v", second, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package fatigue owns daily fatigue-limit reconciliation rules shared by
|
||||
// character selection and live account-entitlement activation.
|
||||
package fatigue
|
||||
|
||||
const (
|
||||
DefaultLimit int64 = 156
|
||||
BlackDiamondLimit int64 = 188
|
||||
FatigueBatteryStatKey = "fatigue_battery"
|
||||
FatigueBatteryChargeStatKey = "fatigue_battery_charge"
|
||||
FatigueBatteryChargeLimit int64 = 20000
|
||||
FatigueBatteryActivation int64 = 156
|
||||
FatigueBatteryPerBuff int64 = 5
|
||||
FatigueBatteryDayCapacity1 int64 = 78
|
||||
FatigueBatteryDayCapacity2 int64 = 117
|
||||
FatigueBatteryDayCapacity3 int64 = 156
|
||||
FatigueBatteryDayCapacity4 int64 = 234
|
||||
)
|
||||
|
||||
// RemainingStat reads a character's remaining fatigue out of its stats map.
|
||||
//
|
||||
// Exported because more than one projection needs the same number the dungeon
|
||||
// entry path consumes: the op7 mode-10 peer prompt renders 剩余疲劳值, and
|
||||
// re-listing the keys at that call site is how the two would drift apart. See
|
||||
// remainingStat for the legacy spellings it also accepts.
|
||||
func RemainingStat(stats map[string]int64) (int64, bool) {
|
||||
return remainingStat(stats)
|
||||
}
|
||||
|
||||
// BatteryBuffCount is the native counter: the tooltip displays count - 1.
|
||||
// sub_2691300 also disables the bonus at the current client's level cap.
|
||||
func BatteryBuffCount(remaining int64, level int, roomActive bool) uint16 {
|
||||
if level >= 90 {
|
||||
return 0
|
||||
}
|
||||
remaining = min(max(remaining, 0), BatteryCapacity(level))
|
||||
if remaining == 0 && !roomActive {
|
||||
return 0
|
||||
}
|
||||
return uint16(remaining + 1)
|
||||
}
|
||||
|
||||
// BatteryCapacity returns the PVF fatigue-battery display capacity for a
|
||||
// character level. The level bands are the 99-entry serverparameter table:
|
||||
// 1-19, 20-29, 30-39, and 40+.
|
||||
func BatteryCapacity(level int) int64 {
|
||||
switch {
|
||||
case level >= 40:
|
||||
return FatigueBatteryDayCapacity4
|
||||
case level >= 30:
|
||||
return FatigueBatteryDayCapacity3
|
||||
case level >= 20:
|
||||
return FatigueBatteryDayCapacity2
|
||||
case level > 0:
|
||||
return FatigueBatteryDayCapacity1
|
||||
default:
|
||||
// Legacy records without a level must not be truncated during migration.
|
||||
return FatigueBatteryDayCapacity4
|
||||
}
|
||||
}
|
||||
|
||||
// Consume deducts up to amount from the persisted remaining fatigue without
|
||||
// allowing a negative value. The consumed return value is the exact durable
|
||||
// delta and is zero for exhausted characters or non-positive requests.
|
||||
func Consume(remaining, amount int64) (next, consumed int64) {
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
if amount <= 0 || remaining == 0 {
|
||||
return remaining, 0
|
||||
}
|
||||
if amount > remaining {
|
||||
amount = remaining
|
||||
}
|
||||
return remaining - amount, amount
|
||||
}
|
||||
|
||||
// DesiredBlackDiamondLimit changes only the two known native membership
|
||||
// limits. Custom/event limits are preserved instead of being overwritten.
|
||||
func DesiredBlackDiamondLimit(current int64, active bool) int64 {
|
||||
if current <= 0 {
|
||||
current = DefaultLimit
|
||||
}
|
||||
if active && current == DefaultLimit {
|
||||
return BlackDiamondLimit
|
||||
}
|
||||
if !active && current == BlackDiamondLimit {
|
||||
return DefaultLimit
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// ReconcileLimit preserves consumed fatigue while switching the maximum.
|
||||
// Example: 120/156 -> 152/188 -> 120/156.
|
||||
func ReconcileLimit(remaining, currentLimit, desiredLimit int64) (int64, int64, bool) {
|
||||
if desiredLimit <= 0 {
|
||||
return remaining, currentLimit, false
|
||||
}
|
||||
originalRemaining, originalLimit := remaining, currentLimit
|
||||
if currentLimit <= 0 {
|
||||
currentLimit = DefaultLimit
|
||||
}
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
if remaining > currentLimit {
|
||||
remaining = currentLimit
|
||||
}
|
||||
if currentLimit == desiredLimit {
|
||||
return remaining, currentLimit, remaining != originalRemaining || currentLimit != originalLimit
|
||||
}
|
||||
used := currentLimit - remaining
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
nextRemaining := desiredLimit - used
|
||||
if nextRemaining < 0 {
|
||||
nextRemaining = 0
|
||||
}
|
||||
return nextRemaining, desiredLimit, true
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package fatigue
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConsumeClampsAtZero(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
remaining int64
|
||||
amount int64
|
||||
wantNext int64
|
||||
wantConsumed int64
|
||||
}{
|
||||
{name: "room", remaining: 100, amount: 1, wantNext: 99, wantConsumed: 1},
|
||||
{name: "last point", remaining: 1, amount: 1, wantNext: 0, wantConsumed: 1},
|
||||
{name: "exhausted", remaining: 0, amount: 1, wantNext: 0, wantConsumed: 0},
|
||||
{name: "negative persisted value", remaining: -1, amount: 1, wantNext: 0, wantConsumed: 0},
|
||||
{name: "non-positive request", remaining: 10, amount: 0, wantNext: 10, wantConsumed: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
next, consumed := Consume(test.remaining, test.amount)
|
||||
if next != test.wantNext || consumed != test.wantConsumed {
|
||||
t.Fatalf("Consume(%d, %d) = %d, %d; want %d, %d", test.remaining, test.amount, next, consumed, test.wantNext, test.wantConsumed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileBlackDiamondLimitPreservesConsumedFatigue(t *testing.T) {
|
||||
remaining, limit, changed := ReconcileLimit(120, DefaultLimit, BlackDiamondLimit)
|
||||
if !changed || remaining != 152 || limit != BlackDiamondLimit {
|
||||
t.Fatalf("activation = %d/%d changed=%t, want 152/188", remaining, limit, changed)
|
||||
}
|
||||
remaining, limit, changed = ReconcileLimit(remaining, limit, DefaultLimit)
|
||||
if !changed || remaining != 120 || limit != DefaultLimit {
|
||||
t.Fatalf("expiry = %d/%d changed=%t, want 120/156", remaining, limit, changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredBlackDiamondLimitPreservesCustomLimit(t *testing.T) {
|
||||
if got := DesiredBlackDiamondLimit(200, true); got != 200 {
|
||||
t.Fatalf("custom active limit=%d, want 200", got)
|
||||
}
|
||||
if got := DesiredBlackDiamondLimit(200, false); got != 200 {
|
||||
t.Fatalf("custom inactive limit=%d, want 200", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileLimitPersistsNormalizedNativeState(t *testing.T) {
|
||||
remaining, limit, changed := ReconcileLimit(999, 0, DefaultLimit)
|
||||
if !changed || remaining != DefaultLimit || limit != DefaultLimit {
|
||||
t.Fatalf("normalized state=%d/%d changed=%t, want 156/156 changed", remaining, limit, changed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package fatigue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("fatigue owner unavailable")
|
||||
ErrCharacterRequired = errors.New("fatigue character id required")
|
||||
ErrCharacterNotFound = errors.New("fatigue character not found")
|
||||
ErrAccountMismatch = errors.New("fatigue character account mismatch")
|
||||
ErrRemainingUnknown = errors.New("fatigue remaining value unavailable")
|
||||
ErrInsufficient = errors.New("fatigue remaining value insufficient")
|
||||
)
|
||||
|
||||
type ConsumeRoomCommand struct {
|
||||
AccountID string
|
||||
CharacterID string
|
||||
Amount int64
|
||||
// RequireFull makes the consumption all-or-nothing: the whole amount must
|
||||
// be payable or nothing is charged. PVP arcade mode prices a run up front
|
||||
// this way, so a partially payable run must not start.
|
||||
RequireFull bool
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ConsumeRoomResult struct {
|
||||
Character dnfrepo.CharacterRecord
|
||||
Before int64
|
||||
After int64
|
||||
Consumed int64
|
||||
BatteryBefore int64
|
||||
BatteryAfter int64
|
||||
BatteryConsumed int64
|
||||
}
|
||||
|
||||
type ReconcileDailyCommand struct {
|
||||
AccountID string
|
||||
CharacterID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ReconcileDailyResult struct {
|
||||
Character dnfrepo.CharacterRecord
|
||||
DailyStatsResult
|
||||
}
|
||||
|
||||
type Owner struct {
|
||||
settlements dnfrepo.CharacterSettlementUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.CharacterSettlement == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{settlements: repositories.CharacterSettlement}, nil
|
||||
}
|
||||
|
||||
// ReconcileDaily restores fatigue and clears every fatigue-potion counter once
|
||||
// per Beijing service day in one character transaction.
|
||||
func (o *Owner) ReconcileDaily(ctx context.Context, command ReconcileDailyCommand) (ReconcileDailyResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return ReconcileDailyResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return ReconcileDailyResult{}, ErrCharacterRequired
|
||||
}
|
||||
accountID := strings.TrimSpace(command.AccountID)
|
||||
updatedAt := normalizedUpdateTime(command.UpdatedAt)
|
||||
var result ReconcileDailyResult
|
||||
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Character == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
|
||||
}
|
||||
if stored := strings.TrimSpace(character.AccountID); stored != "" && accountID != "" && stored != accountID {
|
||||
return fmt.Errorf("%w: character=%s", ErrAccountMismatch, characterID)
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64, 6)
|
||||
}
|
||||
daily := ReconcileDailyStats(character.Stats, updatedAt, character.Level)
|
||||
result = ReconcileDailyResult{Character: character, DailyStatsResult: daily}
|
||||
if !daily.Changed {
|
||||
return nil
|
||||
}
|
||||
character.UpdatedAt = updatedAt
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Character = character
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ReconcileDailyResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ConsumeRoom atomically deducts one committed room's fatigue from the latest
|
||||
// persisted character state. The transaction reload prevents concurrent
|
||||
// progression or membership-limit writes from being overwritten.
|
||||
func (o *Owner) ConsumeRoom(ctx context.Context, command ConsumeRoomCommand) (ConsumeRoomResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return ConsumeRoomResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return ConsumeRoomResult{}, ErrCharacterRequired
|
||||
}
|
||||
accountID := strings.TrimSpace(command.AccountID)
|
||||
var result ConsumeRoomResult
|
||||
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Character == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
|
||||
}
|
||||
if stored := strings.TrimSpace(character.AccountID); stored != "" && accountID != "" && stored != accountID {
|
||||
return fmt.Errorf("%w: character=%s", ErrAccountMismatch, characterID)
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
remaining, known := remainingStat(character.Stats)
|
||||
if !known {
|
||||
return fmt.Errorf("%w: character=%s", ErrRemainingUnknown, characterID)
|
||||
}
|
||||
next, consumed := Consume(remaining, command.Amount)
|
||||
if command.RequireFull && consumed != command.Amount {
|
||||
return ErrInsufficient
|
||||
}
|
||||
battery := min(max(character.Stats[FatigueBatteryStatKey], 0), BatteryCapacity(character.Level))
|
||||
batteryNext, batteryConsumed := battery, int64(0)
|
||||
if consumed > 0 && BatteryBuffCount(battery, character.Level, false) > 0 {
|
||||
batteryNext, batteryConsumed = Consume(battery, consumed)
|
||||
}
|
||||
result = ConsumeRoomResult{
|
||||
Character: character, Before: remaining, After: next, Consumed: consumed,
|
||||
BatteryBefore: battery, BatteryAfter: batteryNext, BatteryConsumed: batteryConsumed,
|
||||
}
|
||||
if consumed == 0 {
|
||||
return nil
|
||||
}
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64, 3)
|
||||
}
|
||||
limit := character.Stats["fatigue_limit"]
|
||||
if limit <= 0 {
|
||||
limit = DefaultLimit
|
||||
if remaining > limit {
|
||||
limit = remaining
|
||||
}
|
||||
character.Stats["fatigue_limit"] = limit
|
||||
}
|
||||
used := limit - next
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
character.Stats["fatigue"] = next
|
||||
character.Stats["fatigue_display_update"] = used
|
||||
character.Stats[FatigueBatteryStatKey] = batteryNext
|
||||
character.UpdatedAt = normalizedUpdateTime(command.UpdatedAt)
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Character = character
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ConsumeRoomResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizedUpdateTime(value time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func remainingStat(stats map[string]int64) (int64, bool) {
|
||||
for _, key := range []string{"fatigue", "fp", "疲劳"} {
|
||||
if value, ok := stats[key]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package fatigue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerConsumeRoomPersistsRemainingAndDisplayUsed(t *testing.T) {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
character := dnfrepo.CharacterRecord{
|
||||
CharacterID: "19",
|
||||
AccountID: "account-1",
|
||||
Stats: map[string]int64{"fatigue": 100, "fatigue_limit": DefaultLimit},
|
||||
}
|
||||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
result, err := owner.ConsumeRoom(context.Background(), ConsumeRoomCommand{
|
||||
AccountID: "account-1", CharacterID: "19", Amount: 1, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Before != 100 || result.After != 99 || result.Consumed != 1 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
stored, found, err := repositories.Character.Load(context.Background(), "19")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load stored character found=%t err=%v", found, err)
|
||||
}
|
||||
if stored.Stats["fatigue"] != 99 || stored.Stats["fatigue_display_update"] != 57 ||
|
||||
!stored.UpdatedAt.Equal(now.UTC()) {
|
||||
t.Fatalf("stored stats=%+v updated_at=%v", stored.Stats, stored.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerConsumeRoomConsumesBatteryAlongsideFatigue(t *testing.T) {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
character := dnfrepo.CharacterRecord{
|
||||
CharacterID: "19", AccountID: "account-1", Level: 89,
|
||||
Stats: map[string]int64{"fatigue": 100, "fatigue_limit": 156, FatigueBatteryStatKey: 12},
|
||||
}
|
||||
if err := repositories.Character.Save(context.Background(), character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := owner.ConsumeRoom(context.Background(), ConsumeRoomCommand{
|
||||
AccountID: "account-1", CharacterID: "19", Amount: 1,
|
||||
})
|
||||
if err != nil || result.Consumed != 1 || result.BatteryConsumed != 1 || result.BatteryAfter != 11 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
stored, _, _ := repositories.Character.Load(context.Background(), "19")
|
||||
if stored.Stats[FatigueBatteryStatKey] != 11 {
|
||||
t.Fatalf("stored battery=%d, want 11", stored.Stats[FatigueBatteryStatKey])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerConsumeRoomBatteryStopsAtLevelCapAndActualFatigue(t *testing.T) {
|
||||
for _, level := range []int{89, 90} {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
ctx := context.Background()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "19", Level: level,
|
||||
Stats: map[string]int64{"fatigue": 1, FatigueBatteryStatKey: 12},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repositories)
|
||||
result, err := owner.ConsumeRoom(ctx, ConsumeRoomCommand{CharacterID: "19", Amount: 5})
|
||||
want := int64(11)
|
||||
if level == 90 {
|
||||
want = 12
|
||||
}
|
||||
if err != nil || result.BatteryAfter != want || result.Consumed != 1 {
|
||||
t.Fatalf("level=%d result=%+v err=%v", level, result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerConsumeRoomClampsLastPointAndLeavesExhaustedStateStable(t *testing.T) {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: "19", AccountID: "account-1",
|
||||
Stats: map[string]int64{"fatigue": 1, "fatigue_limit": DefaultLimit},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repositories)
|
||||
first, err := owner.ConsumeRoom(context.Background(), ConsumeRoomCommand{AccountID: "account-1", CharacterID: "19", Amount: 1})
|
||||
if err != nil || first.After != 0 || first.Consumed != 1 {
|
||||
t.Fatalf("first=%+v err=%v", first, err)
|
||||
}
|
||||
updatedAt := first.Character.UpdatedAt
|
||||
second, err := owner.ConsumeRoom(context.Background(), ConsumeRoomCommand{AccountID: "account-1", CharacterID: "19", Amount: 1})
|
||||
if err != nil || second.After != 0 || second.Consumed != 0 || !second.Character.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("second=%+v err=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerConsumeRoomRejectsAccountMismatch(t *testing.T) {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: "19", AccountID: "account-1", Stats: map[string]int64{"fatigue": 10},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repositories)
|
||||
_, err := owner.ConsumeRoom(context.Background(), ConsumeRoomCommand{AccountID: "account-2", CharacterID: "19", Amount: 1})
|
||||
if !errors.Is(err, ErrAccountMismatch) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package fatigue
|
||||
|
||||
// 本文件只提供一个投影函数,供决斗场(PVP)代码读取"虚弱/状态百分比"。
|
||||
//
|
||||
// 重要:这棵树的 stamina 语义是**惩罚等级 0..10**,不是百分比。
|
||||
// 权威实现在 dnfbridge/weakness_state.go:
|
||||
//
|
||||
// level = clamp(stats["stamina"], 0, 10)
|
||||
// condition = 100 - level*9 (level=9 -> 19%,level=10 -> 10%)
|
||||
//
|
||||
// 决斗场代码(远端玩家 actor / 街头争霸成员行)需要一个"取状态百分比"的入口,
|
||||
// 参考树把它做成了 fatigue.WeaknessValue(stats)。这里给出同名 API,但**保持本树
|
||||
// 的等级语义**,因此它是 dnfbridge 那套虚弱系统的只读投影,不改变任何既有行为,
|
||||
// 也不引入参考树的百分比数据模型——虚弱系统不属于本次决斗场移植范围。
|
||||
const (
|
||||
// WeaknessStatKey 是惩罚等级所在的角色 stat 键。
|
||||
WeaknessStatKey = "stamina"
|
||||
// WeaknessMaximumLevel 与客户端自身的钳位一致。
|
||||
WeaknessMaximumLevel int64 = 10
|
||||
// WeaknessConditionStep 是每一级惩罚损失的状态百分比。
|
||||
WeaknessConditionStep int64 = 9
|
||||
// WeaknessHealthyValue 是客户端视为健康的状态百分比。
|
||||
WeaknessHealthyValue int64 = 100
|
||||
)
|
||||
|
||||
// WeaknessValue 把持久化的惩罚等级投影成客户端消费的状态百分比。
|
||||
// Stats 为 nil、缺键或为负时按 0 级处理(=100%)。
|
||||
func WeaknessValue(stats map[string]int64) int64 {
|
||||
level := stats[WeaknessStatKey]
|
||||
if level < 0 {
|
||||
level = 0
|
||||
}
|
||||
if level > WeaknessMaximumLevel {
|
||||
level = WeaknessMaximumLevel
|
||||
}
|
||||
condition := WeaknessHealthyValue - level*WeaknessConditionStep
|
||||
if condition < 0 {
|
||||
return 0
|
||||
}
|
||||
return condition
|
||||
}
|
||||
Reference in new issue
Block a user