Files
90CNServer/server/internal/modules/dnf/quest/auto_complete_items_test.go
T
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

305 lines
14 KiB
Go

package quest
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestAutoCompleteCarryForwardRepairGrantsOnlyOutstandingStoryItemOnce(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "17",
Job: "2",
Level: 35,
Stats: map[string]int64{"grow_type": 0},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "17",
Slots: map[string]dnfrepo.ItemStack{"0:10": {ItemID: 9001, Count: 3}},
}); err != nil {
t.Fatal(err)
}
completedNoReward := dnfrepo.QuestState{
Status: "completed",
Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
"reward_state": finishRewardGranted,
},
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "17",
States: map[int64]dnfrepo.QuestState{
100: completedNoReward,
101: completedNoReward,
300: {Status: "active"},
},
}); err != nil {
t.Fatal(err)
}
// 101 rewards an item that only a [side] consumer wants; a side quest is
// not a story consumer, so 7002 must not be granted.
definitions := []Definition{
{ID: 100, Path: "n_quest/producer.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 101, Path: "n_quest/unrelated.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7002, Count: 5}}},
{ID: 200, Path: "n_quest/parent.qst", Grade: "[epic]", Type: "[quest clear]", Job: "[all]", LevelMin: 1, LevelMax: 99, ExposedByNPC: 1, IntData: []int64{250}},
{ID: 250, Path: "n_quest/consumer.qst", Grade: "[sub]", Type: "[seek n meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 200, IntData: []int64{7001, 1, 303}},
{ID: 300, Path: "n_quest/unrelated_side_consumer.qst", Grade: "[side]", Type: "[seeking]", Job: "[all]", LevelMin: 1, LevelMax: 99, ExposedByNPC: 1, IntData: []int64{7002, 1}},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
input := AutoCompleteCarryForwardRepairInput{
CharacterID: "17",
RepairedAt: time.Date(2026, 9, 5, 10, 30, 0, 0, time.UTC),
AllocateItem: finishOwnerAllocator(nil),
}
result, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, input)
if err != nil {
t.Fatal(err)
}
if result.Idempotent || len(result.Items) != 1 || result.Items[0].ItemID != 7001 || result.Items[0].Delta != 1 || result.Items[0].PostCount != 1 {
t.Fatalf("carry-forward result=%+v", result)
}
stored, found, err := repositories.Inventory.Load(ctx, "17")
if err != nil || !found || countItemInMain(stored, 7001) != 1 || countItemInMain(stored, 7002) != 0 {
t.Fatalf("stored inventory found=%t err=%v record=%+v", found, err, stored)
}
replayed, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, input)
if err != nil {
t.Fatal(err)
}
if !replayed.Idempotent || len(replayed.Items) != 0 {
t.Fatalf("replayed carry-forward=%+v", replayed)
}
stored, found, err = repositories.Inventory.Load(ctx, "17")
if err != nil || !found || countItemInMain(stored, 7001) != 1 {
t.Fatalf("replay duplicated inventory found=%t err=%v record=%+v", found, err, stored)
}
}
// Mirrors the 3509 shape: several producers each reward one fragment, a
// composite [quest clear] producer both rewards and consumes fragments through
// its children, and the consumer asks for the cumulative total.
func TestAutoCompleteCarryForwardRepairAggregatesCompositeStoryItemBalance(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "21",
Job: "6",
Level: 62,
Stats: map[string]int64{"grow_type": 20},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "21", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
completedNoReward := dnfrepo.QuestState{
Status: "completed",
Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
"reward_state": finishRewardGranted,
},
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "21",
States: map[int64]dnfrepo.QuestState{
100: completedNoReward,
110: completedNoReward,
120: completedNoReward,
300: {Status: "active"},
},
}); err != nil {
t.Fatal(err)
}
definitions := []Definition{
{ID: 100, Path: "n_quest/producer_1.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 110, Path: "n_quest/producer_2.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 2}}},
{ID: 120, Path: "n_quest/composite.qst", Grade: "[epic]", Type: "[quest clear]", Job: "[all]", LevelMin: 1, LevelMax: 99, IntData: []int64{121, 122, 123}},
{ID: 121, Path: "n_quest/composite_reward_1.qst", Grade: "[sub]", Type: "[hunt monster]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 2}}},
{ID: 122, Path: "n_quest/composite_exchange.qst", Grade: "[sub]", Type: "[seeking]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120, IntData: []int64{7001, 3}, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 8}}},
{ID: 123, Path: "n_quest/composite_report.qst", Grade: "[sub]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120},
{ID: 300, Path: "n_quest/consumer.qst", Grade: "[epic]", Type: "[seek n meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, ExposedByNPC: 1, IntData: []int64{7001, 10, 303}},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
input := AutoCompleteCarryForwardRepairInput{
CharacterID: "21",
RepairedAt: time.Date(2026, 9, 5, 13, 0, 0, 0, time.UTC),
AllocateItem: finishOwnerAllocator(nil),
}
result, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, input)
if err != nil {
t.Fatal(err)
}
// 1 + 2 + 2 + 8 rewarded, 3 consumed by the composite's own exchange step:
// net 10, exactly what the consumer asks for.
if result.Idempotent || len(result.Items) != 1 || result.Items[0].ItemID != 7001 || result.Items[0].Delta != 10 || result.Items[0].PostCount != 10 {
t.Fatalf("cumulative carry-forward result=%+v", result)
}
replayed, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, input)
if err != nil {
t.Fatal(err)
}
if !replayed.Idempotent || len(replayed.Items) != 0 {
t.Fatalf("replayed cumulative carry-forward=%+v", replayed)
}
}
// The producer is auto-completed under the cutoff while the consumer's parent
// sits above it. This is the level-35 shape of 3226 -> 3249/3610: parent
// closure never reaches 3610, so the item has to travel with the completion.
func TestAutoCompleteMainPreservesStoryItemRequiredByNewSuccessor(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
Job: "2",
Level: 35,
Stats: map[string]int64{"grow_type": 0},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{100: {Status: "active"}},
}); err != nil {
t.Fatal(err)
}
definitions := []Definition{
{ID: 100, Path: "n_quest/producer.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 10, LevelMax: 99, ExposedByNPC: 1, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 200, Path: "n_quest/parent.qst", Grade: "[epic]", Type: "[quest clear]", Job: "[all]", LevelMin: 35, LevelMax: 99, ExposedByNPC: 1, PreRequiredGroups: [][]int64{{100}}, IntData: []int64{250}},
{ID: 250, Path: "n_quest/consumer.qst", Grade: "[sub]", Type: "[seek n meet npc]", Job: "[all]", LevelMin: 35, LevelMax: 99, MainQuestID: 200, IntData: []int64{7001, 1, 303}},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyAutoCompleteMain(ctx, catalog, AutoCompleteMainInput{
CharacterID: "19",
Eligibility: CharacterEligibility{Level: 35, Job: 2, GrowType: 0},
CutoffLevel: 34,
TargetQuestID: 100,
CompletedAt: time.Date(2026, 9, 5, 11, 0, 0, 0, time.UTC),
AllocateItem: finishOwnerAllocator(nil),
})
if err != nil {
t.Fatal(err)
}
if len(result.ChangedQuestIDs) != 1 || result.ChangedQuestIDs[0] != 100 || len(result.CarryForwardItems) != 1 || result.CarryForwardItems[0].ItemID != 7001 || result.Idempotent {
t.Fatalf("auto-complete carry-forward result=%+v", result)
}
stored, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found || countItemInMain(stored, 7001) != 1 {
t.Fatalf("stored inventory found=%t err=%v record=%+v", found, err, stored)
}
// Without an allocator the quest side still commits; the item repair is
// what needs one, and it must say so rather than silently skip.
if _, err := owner.ApplyAutoCompleteMain(ctx, catalog, AutoCompleteMainInput{
CharacterID: "19",
Eligibility: CharacterEligibility{Level: 35, Job: 2, GrowType: 0},
CutoffLevel: 34,
TargetQuestID: 100,
CompletedAt: time.Date(2026, 9, 5, 11, 1, 0, 0, time.UTC),
}); err != nil {
t.Fatalf("replay without allocator must be idempotent (item already held): %v", err)
}
}
// The bulk pass stops before a [quest clear] parent whose children carry their
// own rewards, so the producers inside that parent are ones the player still
// earns by playing it. Carrying forward only what the pass withheld is the
// whole contract; demanding that it cover the child step's full requirement
// rejected the entire operation instead. This is the live 3449/3456 -> 3458
// (3472 awards 2, 3473 needs 3) shape.
func TestAutoCompleteCarryForwardGrantsOnlyTheWithheldShareOfAnUnreachedComposite(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "23", Job: "6", Level: 62, Stats: map[string]int64{"grow_type": 20},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "23", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
completedNoReward := dnfrepo.QuestState{
Status: "completed",
Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
"reward_state": finishRewardGranted,
},
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "23",
States: map[int64]dnfrepo.QuestState{
100: completedNoReward,
110: completedNoReward,
120: {Status: "active"},
},
}); err != nil {
t.Fatal(err)
}
definitions := []Definition{
{ID: 100, Path: "n_quest/producer_1.qst", Grade: "[epic]", Type: "[clear map]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 110, Path: "n_quest/producer_2.qst", Grade: "[epic]", Type: "[clear map]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 120, Path: "n_quest/composite.qst", Grade: "[epic]", Type: "[quest clear]", Job: "[all]", LevelMin: 1, LevelMax: 99, ExposedByNPC: 1, IntData: []int64{121, 122}},
{ID: 121, Path: "n_quest/composite_hunt.qst", Grade: "[sub]", Type: "[hunt monster]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 2}}},
{ID: 122, Path: "n_quest/composite_exchange.qst", Grade: "[sub]", Type: "[seeking]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120, IntData: []int64{7001, 3}, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 4}}},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, AutoCompleteCarryForwardRepairInput{
CharacterID: "23",
RepairedAt: time.Date(2026, 9, 10, 13, 0, 0, 0, time.UTC),
AllocateItem: finishOwnerAllocator(nil),
})
if err != nil {
t.Fatalf("unreached composite must not fail the pass: %v", err)
}
// 1 + 1 withheld by the two auto-completed producers. The remaining two of
// the child step's three come from 121, which the player still plays.
if result.Idempotent || len(result.Items) != 1 || result.Items[0].ItemID != 7001 ||
result.Items[0].Delta != 2 || result.Items[0].PostCount != 2 {
t.Fatalf("withheld-share carry-forward result=%+v", result)
}
}