Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

403 lines
13 KiB
Go

package dungeonadmission
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrDailyInvitationInvalid = errors.New("daily dungeon invitation definition is invalid")
ErrDailyInvitationInventoryFull = errors.New("daily dungeon invitation inventory page is full")
ErrDailyInvitationInventoryAbsent = errors.New("daily dungeon invitation inventory is missing")
)
// DailyInvitation is an active-PVF-resolved admission item that is checked
// once per Beijing service day. Minimum is a floor, not an additive reward:
// an existing stack satisfies the daily login grant without increasing it.
type DailyInvitation struct {
ItemID int64
Minimum int64
StackLimit int64
ListType byte
CheckSlotStart int16
CheckSlotEnd int16
SlotStart int16
SlotEnd int16
PVFPath string
StackableType string
}
type DailyInvitationResult struct {
Invitation DailyInvitation
HeldBefore int64
Granted int64
ChangedSlots []int16
Replayed bool
}
// DailyResetResult describes one idempotent login/06:00 reconciliation. Only
// PeriodDaily policies are normalized; weekly state is deliberately ignored.
type DailyResetResult struct {
Window Window
Snapshots []Snapshot
ResetPolicyKeys []string
Invitations []DailyInvitationResult
CountersChanged bool
InvitationChecksChanged bool
InventoryChanged bool
Changed bool
}
// ReconcileDailyReset atomically advances every supplied daily admission
// counter to the current Beijing-06:00 window and performs the once-per-day
// login invitation check. A service-day marker is committed with the item
// mutation, so consuming the item and relogging cannot mint another copy.
func (o *Owner) ReconcileDailyReset(
ctx context.Context,
characterID string,
policies []Policy,
invitations []DailyInvitation,
now time.Time,
) (DailyResetResult, error) {
if o == nil || o.repositories.CharacterSettlement == nil {
return DailyResetResult{}, ErrOwnerUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return DailyResetResult{}, fmt.Errorf("%w: character is required", ErrCommandInvalid)
}
dailyPolicies, err := normalizeDailyResetPolicies(policies)
if err != nil {
return DailyResetResult{}, err
}
for _, invitation := range invitations {
if err := validateDailyInvitation(invitation); err != nil {
return DailyResetResult{}, err
}
}
if len(dailyPolicies) == 0 && len(invitations) == 0 {
return DailyResetResult{}, nil
}
ctx = contextOrBackground(ctx)
if now.IsZero() {
now = time.Now()
}
now = now.UTC()
window, err := ServiceWindow(Policy{
Key: "daily_reset.service_window", Scope: ScopeCharacter, Period: PeriodDaily, Limit: 1,
}, now)
if err != nil {
return DailyResetResult{}, err
}
result := DailyResetResult{Window: window}
err = o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Account == nil || tx.Settings == nil || tx.Inventory == nil {
return ErrOwnerUnavailable
}
character, found, loadErr := tx.Character.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !found {
return ErrCharacterMissing
}
accountID := strings.TrimSpace(character.AccountID)
if accountID == "" {
return ErrAccountMissing
}
account, found, loadErr := tx.Account.Load(ctx, accountID)
if loadErr != nil {
return loadErr
}
if !found {
return ErrAccountMissing
}
account = dnfrepo.CloneAccount(account)
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
settingsScope := characterCounterSettingsScope(characterID)
settings, settingsFound, loadErr := tx.Settings.Load(ctx, settingsScope)
if loadErr != nil {
return loadErr
}
settings = dnfrepo.CloneSettings(settings)
if !settingsFound {
settings.Scope = settingsScope
}
if settings.Values == nil {
settings.Values = make(map[string]string)
}
result.Snapshots = make([]Snapshot, 0, len(dailyPolicies))
for _, policy := range dailyPolicies {
policyWindow, windowErr := ServiceWindow(policy, now)
if windowErr != nil {
return windowErr
}
values := settings.Values
if policy.Scope == ScopeAccount {
values = account.Metadata
}
prefix := storagePrefix + digestString(policy.Key) + ":"
periodUnix := policyWindow.Start.Unix()
storedPeriod, periodOK := parseStoredInt(values[prefix+"period"])
used64, usedOK := parseStoredUint(values[prefix+"used"])
bonus64, bonusOK := parseStoredUint(values[prefix+"bonus"])
periodCurrent := periodOK && storedPeriod == periodUnix && usedOK
if !periodCurrent {
clearPolicyState(values, prefix)
values[prefix+"period"] = strconv.FormatInt(periodUnix, 10)
values[prefix+"used"] = "0"
used64 = 0
bonus64 = 0
bonusOK = true
result.CountersChanged = true
result.ResetPolicyKeys = append(result.ResetPolicyKeys, policy.Key)
}
if !bonusOK || bonus64 > uint64(math.MaxUint32-policy.Limit) {
bonus64 = 0
if _, present := values[prefix+"bonus"]; present {
delete(values, prefix+"bonus")
result.CountersChanged = true
}
}
effectiveLimit := policy.Limit + uint32(bonus64)
if used64 > uint64(effectiveLimit) {
used64 = uint64(effectiveLimit)
values[prefix+"used"] = strconv.FormatUint(used64, 10)
result.CountersChanged = true
}
result.Snapshots = append(result.Snapshots, Snapshot{
Policy: policy, Window: policyWindow, Used: uint32(used64), Bonus: uint32(bonus64),
EffectiveLimit: effectiveLimit, Remaining: effectiveLimit - uint32(used64),
})
}
inventory, inventoryFound, loadErr := tx.Inventory.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !inventoryFound {
return ErrDailyInvitationInventoryAbsent
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
result.Invitations = make([]DailyInvitationResult, 0, len(invitations))
for _, invitation := range invitations {
markerKey := dailyInvitationMarkerKey(invitation)
markerPeriod, markerOK := parseStoredInt(settings.Values[markerKey])
if markerOK && markerPeriod == window.Start.Unix() {
result.Invitations = append(result.Invitations, DailyInvitationResult{Invitation: invitation, Replayed: true})
continue
}
invitationResult, ensureErr := ensureDailyInvitation(&inventory, invitation)
if ensureErr != nil {
return ensureErr
}
settings.Values[markerKey] = strconv.FormatInt(window.Start.Unix(), 10)
result.InvitationChecksChanged = true
result.Invitations = append(result.Invitations, invitationResult)
if invitationResult.Granted > 0 {
result.InventoryChanged = true
}
}
result.Changed = result.CountersChanged || result.InvitationChecksChanged || result.InventoryChanged
if result.InventoryChanged {
inventory.UpdatedAt = now
if saveErr := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
return saveErr
}
}
// A first daily invitation check changes only its marker when the item
// already exists, so settings must still be saved even without a grant.
if result.CountersChanged || result.InvitationChecksChanged {
settings.UpdatedAt = now
if saveErr := dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues); saveErr != nil {
return saveErr
}
}
if result.CountersChanged {
account.UpdatedAt = now
if saveErr := tx.Account.Save(ctx, account); saveErr != nil {
return saveErr
}
}
return nil
})
if err != nil {
return DailyResetResult{}, err
}
return result, nil
}
func normalizeDailyResetPolicies(policies []Policy) ([]Policy, error) {
if len(policies) == 0 {
return nil, nil
}
normalized, err := normalizePolicies(policies)
if err != nil {
return nil, err
}
daily := make([]Policy, 0, len(normalized))
for _, policy := range normalized {
if policy.Period == PeriodDaily {
daily = append(daily, policy)
}
}
return daily, nil
}
func validateDailyInvitation(invitation DailyInvitation) error {
checkSlotStart, checkSlotEnd := dailyInvitationCheckSlotRange(invitation)
if invitation.ItemID <= 0 || invitation.Minimum <= 0 || invitation.StackLimit < 0 ||
invitation.ListType != dnfrepo.MainInventoryListType || invitation.SlotStart < 0 || invitation.SlotEnd < invitation.SlotStart ||
checkSlotStart < 0 || checkSlotEnd < checkSlotStart ||
strings.TrimSpace(invitation.PVFPath) == "" || strings.TrimSpace(invitation.StackableType) == "" {
return fmt.Errorf("%w: item=%d minimum=%d stack_limit=%d list=%d check_slots=%d..%d target_slots=%d..%d path=%q type=%q",
ErrDailyInvitationInvalid, invitation.ItemID, invitation.Minimum, invitation.StackLimit,
invitation.ListType, checkSlotStart, checkSlotEnd, invitation.SlotStart, invitation.SlotEnd,
invitation.PVFPath, invitation.StackableType)
}
return nil
}
func dailyInvitationCheckSlotRange(invitation DailyInvitation) (int16, int16) {
if invitation.CheckSlotStart == 0 && invitation.CheckSlotEnd == 0 {
return invitation.SlotStart, invitation.SlotEnd
}
return invitation.CheckSlotStart, invitation.CheckSlotEnd
}
func dailyInvitationMarkerKey(invitation DailyInvitation) string {
identity := strconv.FormatInt(invitation.ItemID, 10) + ":" + strings.ToLower(strings.TrimSpace(invitation.PVFPath))
return storagePrefix + "daily_invitation:" + digestString(identity) + ":period"
}
func ensureDailyInvitation(inventory *dnfrepo.InventoryRecord, invitation DailyInvitation) (DailyInvitationResult, error) {
result := DailyInvitationResult{Invitation: invitation}
checkSlotStart, checkSlotEnd := dailyInvitationCheckSlotRange(invitation)
for slot := checkSlotStart; slot <= checkSlotEnd; slot++ {
stack, found := inventory.Slots[dailyInvitationSlotKey(invitation.ListType, slot)]
if found && stack.ItemID == invitation.ItemID && stack.Count > 0 {
if result.HeldBefore > math.MaxInt64-stack.Count {
result.HeldBefore = math.MaxInt64
} else {
result.HeldBefore += stack.Count
}
}
}
remaining := invitation.Minimum - result.HeldBefore
if remaining <= 0 {
return result, nil
}
// PVF [stack limit] 是这个容器里该道具的总量上限:额度按整个容器里同 id 总量算
// (不能只看 check 范围),容器里已经有该道具的行时不能再开第二行。
newRowStackLimit := int64(0)
space := dnfrepo.ContainerUncappedSpace
if invitation.StackLimit > 0 {
newRowStackLimit = invitation.StackLimit
space = dnfrepo.ContainerItemSpace(inventory.Slots, invitation.ListType, invitation.ItemID, invitation.StackLimit)
}
markChanged := func(slot int16) {
for _, changed := range result.ChangedSlots {
if changed == slot {
return
}
}
result.ChangedSlots = append(result.ChangedSlots, slot)
}
for slot := invitation.SlotStart; slot <= invitation.SlotEnd && remaining > 0; slot++ {
key := dailyInvitationSlotKey(invitation.ListType, slot)
stack, found := inventory.Slots[key]
if !found || stack.ItemID != invitation.ItemID || stack.Count <= 0 || invitation.StackLimit == 1 {
continue
}
capacity := remaining
if invitation.StackLimit > 0 {
capacity = invitation.StackLimit - stack.Count
}
if capacity > space {
capacity = space
}
if capacity <= 0 {
continue
}
add := remaining
if add > capacity {
add = capacity
}
stack = cloneDailyInvitationStack(stack)
stack.Count += add
inventory.Slots[key] = stack
remaining -= add
space -= add
result.Granted += add
markChanged(slot)
}
for slot := invitation.SlotStart; slot <= invitation.SlotEnd && remaining > 0; slot++ {
key := dailyInvitationSlotKey(invitation.ListType, slot)
if _, occupied := inventory.Slots[key]; occupied {
continue
}
if !dnfrepo.CanOpenContainerItemRow(inventory.Slots, invitation.ListType, invitation.ItemID, newRowStackLimit) {
break
}
insert := remaining
if invitation.StackLimit > 0 && insert > invitation.StackLimit {
insert = invitation.StackLimit
}
if insert > space {
insert = space
}
if insert <= 0 {
break
}
inventory.Slots[key] = dnfrepo.ItemStack{
ItemID: invitation.ItemID,
Count: insert,
Extra: map[string]string{
"item_kind": "stackable",
"pvf_path": invitation.PVFPath,
"stackable_type": invitation.StackableType,
},
}
remaining -= insert
space -= insert
result.Granted += insert
markChanged(slot)
}
if remaining > 0 {
return DailyInvitationResult{}, fmt.Errorf("%w: item=%d remaining=%d slots=%d..%d",
ErrDailyInvitationInventoryFull, invitation.ItemID, remaining, invitation.SlotStart, invitation.SlotEnd)
}
return result, nil
}
func dailyInvitationSlotKey(listType byte, slot int16) string {
return fmt.Sprintf("%d:%d", listType, slot)
}
func cloneDailyInvitationStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
if stack.Extra != nil {
extra := make(map[string]string, len(stack.Extra))
for key, value := range stack.Extra {
extra[key] = value
}
stack.Extra = extra
}
return stack
}