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,365 @@
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()
}
@@ -0,0 +1,163 @@
package achievement
import (
"context"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
type definitionStub Definition
func (r definitionStub) ResolveAchievementDefinition(context.Context, int32) (Definition, error) {
return Definition(r), nil
}
func TestOwnerTriggerPersistsRealTargetAndGrantsTitleOnce(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos, definitionStub{
QuestID: 6533,
Target1: 4,
Reward: TitleReward{
ItemID: 9001,
Category: 2,
BookIndex: 7,
},
})
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
first, err := owner.Trigger(ctx, Command{
SelectedCharacterID: 19,
QuestID: 6533,
Delta1: 1,
})
if err != nil {
t.Fatalf("first Trigger: %v", err)
}
if first.Remain1 != 3 || first.Completed {
t.Fatalf("first result = %+v", first)
}
second, err := owner.Trigger(ctx, Command{
SelectedCharacterID: 19,
QuestID: 6533,
Delta1: 3,
})
if err != nil {
t.Fatalf("second Trigger: %v", err)
}
if !second.Completed || !second.TitleGranted || second.TitleSlot != 2007 {
t.Fatalf("second result = %+v", second)
}
third, err := owner.Trigger(ctx, Command{
SelectedCharacterID: 19,
QuestID: 6533,
Delta1: 1,
})
if err != nil {
t.Fatalf("third Trigger: %v", err)
}
if third.Completed || third.TitleGranted || !third.AlreadyCompleted ||
third.Remain1 != 0 || third.Remain2 != 0 || third.Remain3 != 0 {
t.Fatalf("third result = %+v", third)
}
inventory, found, err := repos.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if title := inventory.Slots["100:2007"]; title.ItemID != 9001 || title.Count != 1 {
t.Fatalf("title = %+v", title)
}
for key, stack := range inventory.Slots {
if IsLegacyInventoryProgress(key, stack) {
t.Fatalf("achievement progress leaked into title inventory: %s=%+v", key, stack)
}
}
quests, found, err := repos.Quest.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load quests found=%t err=%v", found, err)
}
progress := Snapshot(quests)
if len(progress) != 1 || progress[0].QuestID != 6533 || !progress[0].Completed {
t.Fatalf("progress = %+v", progress)
}
}
func TestOwnerTriggerFallsBackToFirstFreeTitleSlot(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"100:2007": {ItemID: 8001, Count: 1},
"100:2000": {ItemID: 8002, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos, definitionStub{
QuestID: 6533,
Target1: 1,
Reward: TitleReward{ItemID: 9001, Category: 2, BookIndex: 7},
})
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
result, err := owner.Trigger(ctx, Command{SelectedCharacterID: 19, QuestID: 6533, Delta1: 1})
if err != nil {
t.Fatalf("Trigger: %v", err)
}
if result.TitleSlot != 2001 {
t.Fatalf("title slot = %d, want 2001", result.TitleSlot)
}
}
func TestRepairLegacyRowsRemovesOnlyBrokenProgressSignature(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"100:933": {
ItemID: 6533,
Count: 1,
Extra: map[string]string{
"initialized": "1",
"quest_id": "6533",
"remain1": "1",
},
},
"100:7": {ItemID: 9001, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos, nil)
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
removed, err := owner.RepairLegacyRows(ctx, 19)
if err != nil {
t.Fatalf("RepairLegacyRows: %v", err)
}
if removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
inventory, _, _ := repos.Inventory.Load(ctx, "19")
if _, found := inventory.Slots["100:933"]; found {
t.Fatalf("legacy progress survived: %+v", inventory.Slots["100:933"])
}
if inventory.Slots["100:7"].ItemID != 9001 {
t.Fatalf("real title was removed: %+v", inventory.Slots)
}
}