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,362 @@
package quest
import (
"context"
"errors"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// buildGiveUpCatalog 造两个任务:3145(被放弃的收集类任务)+ 3146(另一个进行中任务)。
func buildGiveUpCatalog(t *testing.T, abandonedExtra, otherExtra string) *Catalog {
t.Helper()
source := catalogTestSource{
DefaultList: "3145 `giveup.qst`\n3146 `other.qst`\n",
"n_quest/giveup.qst": questCatalogTestDefinition("[daily]", 1, 99, "[all]", abandonedExtra),
"n_quest/other.qst": questCatalogTestDefinition("[daily]", 1, 99, "[all]", otherExtra),
}
return buildCatalogFromSource(t, source)
}
func buildCatalogFromSource(t *testing.T, source catalogTestSource) *Catalog {
t.Helper()
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
// 普通材料永远不会进回收名单:这正是 2026-09-22 角色 1111 / 任务 8557 的形状
// (`[seeking]` + `[int data] 10094602 10`,而 10094602 是 `[material]` 可交易材料)。
func TestPlanGiveUpReclaimSkipsOrdinaryMaterials(t *testing.T) {
catalog := buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n10094602 10\n", "")
record := dnfrepo.QuestRecord{CharacterID: "18", States: map[int64]dnfrepo.QuestState{
3145: {Status: "active"},
}}
if plan := PlanGiveUpReclaim(catalog, record, 3145, nil); len(plan) != 0 {
t.Fatalf("ordinary material reclaim plan = %+v, want empty", plan)
}
if plan := PlanGiveUpReclaim(catalog, record, 3145, func(int64) bool { return false }); len(plan) != 0 {
t.Fatalf("classifier-false reclaim plan = %+v, want empty", plan)
}
}
// 任务专属(`[quest]` 家族)道具与 `[depend give item]` 事件道具要进回收名单。
func TestPlanGiveUpReclaimIncludesQuestFamilyAndActivationItems(t *testing.T) {
catalog := buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n1001 10\n", "")
record := dnfrepo.QuestRecord{CharacterID: "18", States: map[int64]dnfrepo.QuestState{
3145: {Status: "active"},
}}
plan := PlanGiveUpReclaim(catalog, record, 3145, func(itemID int64) bool { return itemID == 1001 })
if len(plan) != 1 || plan[0].ItemID != 1001 || plan[0].RetainCount != 0 {
t.Fatalf("quest-family reclaim plan = %+v, want item 1001 retain 0", plan)
}
catalogEvent := buildGiveUpCatalog(t, "[type]\n`[clear map]`\n[depend give item]\n2002 1\n", "")
planEvent := PlanGiveUpReclaim(catalogEvent, record, 3145, nil)
if len(planEvent) != 1 || planEvent[0].ItemID != 2002 {
t.Fatalf("activation-item reclaim plan = %+v, want item 2002", planEvent)
}
}
// 其它仍在进行中的任务需要的数量必须留下(参考端的 retainCount)。
func TestPlanGiveUpReclaimRetainsOtherActiveQuestRequirements(t *testing.T) {
catalog := buildGiveUpCatalog(t,
"[type]\n`[seeking]`\n[int data]\n1001 10\n",
"[type]\n`[seeking]`\n[int data]\n1001 3\n")
record := dnfrepo.QuestRecord{
CharacterID: "18",
States: map[int64]dnfrepo.QuestState{
3145: {Status: "active"},
3146: {Status: "active"},
3147: {Status: "completed"}, // 已完成的任务不占额度
},
}
plan := PlanGiveUpReclaim(catalog, record, 3145, func(int64) bool { return true })
if len(plan) != 1 || plan[0].RetainCount != 3 {
t.Fatalf("retain plan = %+v, want item 1001 retain 3", plan)
}
record.States[3146] = dnfrepo.QuestState{Status: "completed"}
plan = PlanGiveUpReclaim(catalog, record, 3145, func(int64) bool { return true })
if len(plan) != 1 || plan[0].RetainCount != 0 {
t.Fatalf("no-active-successor plan = %+v, want retain 0", plan)
}
}
// `[cant giveup]` 依然是唯一的拒绝理由;收集类任务不再被拦(本次修复的核心)。
func TestOwnerApplyGiveUpHonorsPVFBoundaryAndAllowsCollectionQuests(t *testing.T) {
for _, test := range []struct {
name string
extra string
wantErr error
wantRemoved bool
}{
{name: "cant giveup", extra: "[type]\n`[disjoint item]`\n[cant giveup]\n", wantErr: ErrQuestCannotGiveUp},
{name: "seeking", extra: "[type]\n`[seeking]`\n[int data]\n1001 10\n", wantRemoved: true},
{name: "get item", extra: "[type]\n`[get item]`\n[int data]\n1001 10\n", wantRemoved: true},
{name: "seek n meet npc", extra: "[type]\n`[seek n meet npc]`\n[int data]\n1001 10\n", wantRemoved: true},
{name: "depend give item", extra: "[type]\n`[clear map]`\n[depend give item]\n1001 1\n", wantRemoved: true},
} {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "18", AccountID: "acc-quest"}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "18",
States: map[int64]dnfrepo.QuestState{3145: {Status: "active", ProgressValue: 1}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
_, err = owner.ApplyGiveUp(
ctx,
testAcceptCatalog(t, test.extra),
NewQuestIDCommand(alignedRequest("acc-quest", 18), "giveup_quest", QuestIDRequest{QuestID: 3145}),
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ApplyGiveUp() error = %v, want %v", err, test.wantErr)
}
stored, ok, loadErr := repos.Quest.Load(ctx, "18")
if loadErr != nil || !ok {
t.Fatalf("load quest ok=%t err=%v", ok, loadErr)
}
_, exists := stored.States[3145]
if !test.wantRemoved {
if !exists || stored.States[3145].Status != "active" {
t.Fatalf("rejected give-up mutated quest: %+v", stored.States[3145])
}
return
}
if exists {
t.Fatalf("allowed give-up left quest active: %+v", stored.States[3145])
}
})
}
}
// 收集类任务 + 普通材料:放弃成功,且背包一件都不动(1111/8557 的现场)。
func TestOwnerApplyGiveUpKeepsOrdinaryMaterialsInBag(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "18", AccountID: "acc-quest"}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "18",
States: map[int64]dnfrepo.QuestState{3145: {Status: "active"}},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "18",
Slots: map[string]dnfrepo.ItemStack{"0:121": {ItemID: 10094602, Count: 10}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyGiveUpSettlement(ctx, buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n10094602 10\n", ""),
GiveUpInput{
AccountID: "acc-quest", SelectedCharacterID: 18, QuestID: 3145,
// 物品目录判定 10094602 是 `[material]`:不是任务专属 ⇒ 不回收。
IsQuestItem: func(int64) bool { return false },
})
if err != nil {
t.Fatalf("ApplyGiveUpSettlement() error = %v", err)
}
if len(result.Reclaimed) != 0 || result.ReclaimSkipped || result.HasPostCommitInventory {
t.Fatalf("ordinary-material reclaim = %+v, want none", result)
}
stored, found, err := repos.Quest.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load quest found=%t err=%v", found, err)
}
if _, exists := stored.States[3145]; exists {
t.Fatalf("quest 3145 still active: %+v", stored.States[3145])
}
inventory, found, err := repos.Inventory.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if stack := inventory.Slots["0:121"]; stack.ItemID != 10094602 || stack.Count != 10 {
t.Fatalf("ordinary material changed: %+v", stack)
}
}
// 任务专属道具:放弃成功 + 同事务扣减 + 给出提交后快照;其它任务需要的量要留下。
func TestOwnerApplyGiveUpReclaimsQuestItemsInSameSettlement(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "18", AccountID: "acc-quest"}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "18",
States: map[int64]dnfrepo.QuestState{
3145: {Status: "active"},
3146: {Status: "active"}, // 还需要 1001×3
},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "18",
Slots: map[string]dnfrepo.ItemStack{"0:177": {ItemID: 1001, Count: 6}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
catalog := buildGiveUpCatalog(t,
"[type]\n`[seeking]`\n[int data]\n1001 10\n",
"[type]\n`[seeking]`\n[int data]\n1001 3\n")
result, err := owner.ApplyGiveUpSettlement(ctx, catalog, GiveUpInput{
AccountID: "acc-quest", SelectedCharacterID: 18, QuestID: 3145,
IsQuestItem: func(itemID int64) bool { return itemID == 1001 },
})
if err != nil {
t.Fatalf("ApplyGiveUpSettlement() error = %v", err)
}
if len(result.Reclaimed) != 1 {
t.Fatalf("reclaimed = %+v, want one row", result.Reclaimed)
}
receipt := result.Reclaimed[0]
if receipt.SlotKey != "0:177" || receipt.ItemID != 1001 || receipt.Delta != 3 || receipt.PostCount != 3 {
t.Fatalf("reclaim receipt = %+v, want slot 0:177 item 1001 delta 3 post 3", receipt)
}
if !result.HasPostCommitInventory {
t.Fatal("reclaim without post-commit inventory snapshot")
}
if stack := result.PostCommitInventory.Slots["0:177"]; stack.ItemID != 1001 || stack.Count != 3 {
t.Fatalf("post-commit snapshot = %+v, want item 1001 count 3", stack)
}
inventory, found, err := repos.Inventory.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if stack := inventory.Slots["0:177"]; stack.ItemID != 1001 || stack.Count != 3 {
t.Fatalf("persisted inventory = %+v, want item 1001 count 3", stack)
}
stored, found, err := repos.Quest.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load quest found=%t err=%v", found, err)
}
if _, exists := stored.States[3145]; exists {
t.Fatalf("quest 3145 still active: %+v", stored.States[3145])
}
if stored.States[3146].Status != "active" {
t.Fatalf("other active quest changed: %+v", stored.States[3146])
}
}
// 整行扣完时该行必须从背包里消失。
func TestOwnerApplyGiveUpDeletesEmptiedQuestItemRow(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "18", AccountID: "acc-quest"}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "18",
States: map[int64]dnfrepo.QuestState{3145: {Status: "active"}},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "18",
Slots: map[string]dnfrepo.ItemStack{"0:177": {ItemID: 1001, Count: 2}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyGiveUpSettlement(ctx, buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n1001 2\n", ""),
GiveUpInput{
AccountID: "acc-quest", SelectedCharacterID: 18, QuestID: 3145,
IsQuestItem: func(int64) bool { return true },
})
if err != nil {
t.Fatalf("ApplyGiveUpSettlement() error = %v", err)
}
if len(result.Reclaimed) != 1 || result.Reclaimed[0].Delta != 2 || result.Reclaimed[0].PostCount != 0 {
t.Fatalf("reclaim receipt = %+v, want delta 2 post 0", result.Reclaimed)
}
inventory, found, err := repos.Inventory.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, exists := inventory.Slots["0:177"]; exists {
t.Fatalf("emptied quest-item row survived: %+v", inventory.Slots["0:177"])
}
}
// 没有事务单元时:放弃仍然成功(绝不挡住玩家),只是不回收并如实上报。
func TestOwnerApplyGiveUpWithoutSettlementStillAbandons(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "18", AccountID: "acc-quest"}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "18",
States: map[int64]dnfrepo.QuestState{3145: {Status: "active"}},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "18",
Slots: map[string]dnfrepo.ItemStack{"0:177": {ItemID: 1001, Count: 4}},
}); err != nil {
t.Fatal(err)
}
repos.CharacterSettlement = nil
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyGiveUpSettlement(ctx, buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n1001 10\n", ""),
GiveUpInput{
AccountID: "acc-quest", SelectedCharacterID: 18, QuestID: 3145,
IsQuestItem: func(int64) bool { return true },
})
if err != nil {
t.Fatalf("ApplyGiveUpSettlement() error = %v", err)
}
if !result.ReclaimSkipped || result.ReclaimSkipReason != "character_settlement_transaction_unavailable" {
t.Fatalf("reclaim skip = %t/%q", result.ReclaimSkipped, result.ReclaimSkipReason)
}
stored, found, err := repos.Quest.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load quest found=%t err=%v", found, err)
}
if _, exists := stored.States[3145]; exists {
t.Fatalf("degraded give-up left quest active: %+v", stored.States[3145])
}
inventory, _, err := repos.Inventory.Load(ctx, "18")
if err != nil {
t.Fatal(err)
}
if stack := inventory.Slots["0:177"]; stack.Count != 4 {
t.Fatalf("degraded give-up mutated inventory: %+v", stack)
}
}