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

366 lines
11 KiB
Go

package achievement
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
titleBookListType byte = 100
titleBookSlotBase int16 = 1000
titleBookMaxPerCategory = 200
statusActive = "title_book_active"
statusCompleted = "title_book_completed"
extraInitialized = "title_book_initialized"
extraRemain1 = "title_book_remain_1"
extraRemain2 = "title_book_remain_2"
extraRemain3 = "title_book_remain_3"
)
var (
ErrOwnerUnavailable = errors.New("achievement owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrInventoryNotFound = errors.New("achievement inventory not found")
ErrDefinitionNotFound = errors.New("achievement definition not found")
)
type TitleReward struct {
ItemID int32
Category int32
BookIndex int32
}
type Definition struct {
QuestID int32
Target1 uint16
Target2 uint16
Target3 uint16
Reward TitleReward
}
type DefinitionResolver interface {
ResolveAchievementDefinition(context.Context, int32) (Definition, error)
}
type Command struct {
SelectedCharacterID uint16
QuestID int32
Delta1 uint16
Delta2 uint16
Delta3 uint16
UpdatedAt time.Time
}
type Result struct {
CharacterID string
QuestID int32
Remain1 uint16
Remain2 uint16
Remain3 uint16
Completed bool
AlreadyCompleted bool
TitleGranted bool
TitleItemID int32
TitleCategory int32
TitleSlot int16
LegacyRowsRemoved int
}
type Progress struct {
QuestID int32
Remain1 uint16
Remain2 uint16
Remain3 uint16
Completed bool
}
// Owner atomically owns the durable title-achievement state and its optional
// physical title reward. Progress deliberately lives in Quest.Progress, not
// inventory list 100: list 100 is the client-visible physical title book.
type Owner struct {
settlements dnfrepo.CharacterSettlementUnitOfWork
resolver DefinitionResolver
}
func NewOwner(repos dnfrepo.Group, resolver DefinitionResolver) (*Owner, error) {
if repos.CharacterSettlement == nil || repos.Quest == nil || repos.Inventory == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{settlements: repos.CharacterSettlement, resolver: resolver}, nil
}
func (o *Owner) Trigger(ctx context.Context, cmd Command) (Result, error) {
if o == nil || o.settlements == nil || o.resolver == nil {
return Result{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return Result{}, ErrCharacterRequired
}
definition, err := o.resolver.ResolveAchievementDefinition(ctx, cmd.QuestID)
if err != nil {
return Result{}, err
}
if definition.QuestID != cmd.QuestID ||
(definition.Target1 == 0 && definition.Target2 == 0 && definition.Target3 == 0) {
return Result{}, fmt.Errorf("%w: quest=%d", ErrDefinitionNotFound, cmd.QuestID)
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
result := Result{CharacterID: characterID, QuestID: cmd.QuestID}
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
quests, _, loadErr := tx.Quest.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
quests = dnfrepo.CloneQuest(quests)
quests.CharacterID = characterID
if quests.Progress == nil {
quests.Progress = make(map[int64]dnfrepo.QuestState)
}
inventory, found, loadErr := tx.Inventory.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
for key, stack := range inventory.Slots {
if IsLegacyInventoryProgress(key, stack) {
delete(inventory.Slots, key)
result.LegacyRowsRemoved++
}
}
state, known := quests.Progress[int64(cmd.QuestID)]
if known && strings.EqualFold(strings.TrimSpace(state.Status), statusCompleted) {
result.AlreadyCompleted = true
if result.LegacyRowsRemoved > 0 {
inventory.UpdatedAt = commandTime(cmd.UpdatedAt)
return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots)
}
return nil
}
remain1, remain2, remain3 := definition.Target1, definition.Target2, definition.Target3
if known && strings.EqualFold(strings.TrimSpace(state.Status), statusActive) &&
state.Extra[extraInitialized] == "1" {
remain1 = extraUint16(state.Extra, extraRemain1, definition.Target1)
remain2 = extraUint16(state.Extra, extraRemain2, definition.Target2)
remain3 = extraUint16(state.Extra, extraRemain3, definition.Target3)
}
remain1 = subtractProgress(remain1, cmd.Delta1)
remain2 = subtractProgress(remain2, cmd.Delta2)
remain3 = subtractProgress(remain3, cmd.Delta3)
now := commandTime(cmd.UpdatedAt)
state.Status = statusActive
state.ProgressValue = int64(remain1)
state.UpdatedAt = now
if state.Extra == nil {
state.Extra = make(map[string]string, 4)
}
state.Extra[extraInitialized] = "1"
state.Extra[extraRemain1] = strconv.FormatUint(uint64(remain1), 10)
state.Extra[extraRemain2] = strconv.FormatUint(uint64(remain2), 10)
state.Extra[extraRemain3] = strconv.FormatUint(uint64(remain3), 10)
result.Remain1 = remain1
result.Remain2 = remain2
result.Remain3 = remain3
result.Completed = remain1 == 0 && remain2 == 0 && remain3 == 0
if result.Completed {
state.Status = statusCompleted
state.ProgressValue = 0
grantTitle(&inventory, definition.Reward, cmd.QuestID, &result)
}
quests.Progress[int64(cmd.QuestID)] = state
quests.UpdatedAt = now
inventory.UpdatedAt = now
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, quests, dnfrepo.QuestFieldProgress); err != nil {
return err
}
if result.TitleGranted || result.LegacyRowsRemoved > 0 {
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
return nil
})
if err != nil {
return Result{}, err
}
return result, nil
}
// RepairLegacyRows removes only rows carrying the exact signature emitted by
// the former broken implementation. Their stored counters were initialized
// from the first delta and are not trustworthy enough to migrate.
func (o *Owner) RepairLegacyRows(ctx context.Context, selectedCharacterID uint16) (int, error) {
if o == nil || o.settlements == nil {
return 0, ErrOwnerUnavailable
}
if selectedCharacterID == 0 {
return 0, ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(selectedCharacterID), 10)
removed := 0
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
inventory, found, err := tx.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return nil
}
inventory = dnfrepo.CloneInventory(inventory)
for key, stack := range inventory.Slots {
if IsLegacyInventoryProgress(key, stack) {
delete(inventory.Slots, key)
removed++
}
}
if removed == 0 {
return nil
}
inventory.UpdatedAt = time.Now().UTC()
return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots)
})
return removed, err
}
func Snapshot(record dnfrepo.QuestRecord) []Progress {
rows := make([]Progress, 0)
for questID, state := range record.Progress {
status := strings.ToLower(strings.TrimSpace(state.Status))
if status != statusActive && status != statusCompleted {
continue
}
if questID <= 0 || questID > int64(^uint32(0)) {
continue
}
row := Progress{QuestID: int32(questID), Completed: status == statusCompleted}
if !row.Completed {
row.Remain1 = extraUint16(state.Extra, extraRemain1, 0)
row.Remain2 = extraUint16(state.Extra, extraRemain2, 0)
row.Remain3 = extraUint16(state.Extra, extraRemain3, 0)
}
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].QuestID < rows[j].QuestID })
return rows
}
func IsLegacyInventoryProgress(key string, stack dnfrepo.ItemStack) bool {
if stack.Extra == nil || stack.Extra["initialized"] != "1" {
return false
}
questID, err := strconv.ParseInt(strings.TrimSpace(stack.Extra["quest_id"]), 10, 32)
if err != nil || questID <= 0 || stack.ItemID != questID {
return false
}
parts := strings.Split(key, ":")
if len(parts) != 2 || parts[0] != strconv.Itoa(int(titleBookListType)) {
return false
}
slot, err := strconv.Atoi(parts[1])
return err == nil && slot >= 900 && slot <= 999
}
func grantTitle(inventory *dnfrepo.InventoryRecord, reward TitleReward, questID int32, result *Result) {
if inventory == nil || result == nil || reward.ItemID <= 0 ||
reward.Category < 0 || reward.Category >= 5 {
return
}
for index := int32(0); index < titleBookMaxPerCategory; index++ {
slot := int16(reward.Category)*titleBookSlotBase + int16(index)
if existing, found := inventory.Slots[titleSlotKey(slot)]; found &&
existing.ItemID == int64(reward.ItemID) {
result.TitleItemID = reward.ItemID
result.TitleCategory = reward.Category
result.TitleSlot = slot
return
}
}
targetSlot, ok := firstTitleSlot(inventory.Slots, reward.Category, reward.BookIndex)
if !ok {
return
}
key := titleSlotKey(targetSlot)
inventory.Slots[key] = dnfrepo.ItemStack{
ItemID: int64(reward.ItemID),
Count: 1,
Extra: map[string]string{
"source": "achievement_reward",
"title_book_category": strconv.FormatInt(int64(reward.Category), 10),
"title_book_index": strconv.FormatInt(int64(targetSlot%titleBookSlotBase), 10),
"quest_id": strconv.FormatInt(int64(questID), 10),
},
}
result.TitleGranted = true
result.TitleItemID = reward.ItemID
result.TitleCategory = reward.Category
result.TitleSlot = targetSlot
}
func titleSlotKey(slot int16) string {
return fmt.Sprintf("%d:%d", titleBookListType, slot)
}
func categorySlotKey(category, index int32) string {
return titleSlotKey(int16(category)*titleBookSlotBase + int16(index))
}
func firstTitleSlot(items map[string]dnfrepo.ItemStack, category, preferredIndex int32) (int16, bool) {
if preferredIndex >= 0 && preferredIndex < titleBookMaxPerCategory {
slot := int16(category)*titleBookSlotBase + int16(preferredIndex)
existing, occupied := items[titleSlotKey(slot)]
if !occupied || existing.ItemID <= 0 {
return slot, true
}
}
for index := int32(0); index < titleBookMaxPerCategory; index++ {
existing, occupied := items[categorySlotKey(category, index)]
if !occupied || existing.ItemID <= 0 {
return int16(category)*titleBookSlotBase + int16(index), true
}
}
return -1, false
}
func extraUint16(extra map[string]string, key string, fallback uint16) uint16 {
value, err := strconv.ParseUint(strings.TrimSpace(extra[key]), 10, 16)
if err != nil {
return fallback
}
return uint16(value)
}
func subtractProgress(remaining, delta uint16) uint16 {
if delta >= remaining {
return 0
}
return remaining - delta
}
func commandTime(value time.Time) time.Time {
if value.IsZero() {
return time.Now().UTC()
}
return value.UTC()
}