服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
305 lines
14 KiB
Go
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)
|
|
}
|
|
}
|