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,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
}