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,278 @@
package quest
import (
"context"
"errors"
"slices"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/premium"
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"
)
// dailyQuestResetTestCatalog is the shared fixture for the daily-reset specs. It
// carries one quest per grade class the 06:00 boundary must distinguish:
//
// 1 daily reset
// 2 special daily reset
// 3 normaly repeat never reset (repeatable, not a daily)
// 4 epic never reset (one-shot)
// 5 daily mission reset (the mission tab's daily)
// 6 auto daily mission reset
func dailyQuestResetTestCatalog(t *testing.T) *Catalog {
t.Helper()
source := catalogTestSource{
DefaultList: "1 `daily.qst`\n2 `special.qst`\n3 `repeat.qst`\n4 `epic.qst`\n" +
"5 `dailymission.qst`\n6 `autodailymission.qst`\n",
"n_quest/daily.qst": questCatalogTestDefinition("[daily]", 1, 90, "[gunner]", ""),
"n_quest/special.qst": questCatalogTestDefinition("[special daily]", 1, 90, "[gunner]", ""),
"n_quest/repeat.qst": questCatalogTestDefinition("[normaly repeat]", 1, 90, "[gunner]", ""),
"n_quest/epic.qst": questCatalogTestDefinition("[epic]", 1, 90, "[gunner]", ""),
"n_quest/dailymission.qst": questCatalogTestDefinition("[daily mission]", 1, 90, "[gunner]", ""),
"n_quest/autodailymission.qst": questCatalogTestDefinition("[auto daily mission]", 1, 90, "[gunner]", ""),
}
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
}
// TestDailyResetClassificationCoversTheWholeDailyFamily pins which grades the
// 06:00 boundary clears. Every grade the client's task manual classes as a daily
// counts - including `daily mission` (the mission tab's daily) and
// `auto daily mission`, which the earlier exact-match predicate missed and which
// therefore stayed one-shot forever. `normaly repeat` is repeatable rather than
// daily, so its row survives the boundary.
func TestDailyResetClassificationCoversTheWholeDailyFamily(t *testing.T) {
catalog := dailyQuestResetTestCatalog(t)
for _, probe := range []struct {
name string
questID int64
want bool
}{
{"daily", 1, true},
{"special daily", 2, true},
{"normaly repeat", 3, false},
{"epic", 4, false},
{"daily mission", 5, true},
{"auto daily mission", 6, true},
{"unknown quest id", 99, false},
} {
if got := catalog.IsDailyRepeatable(probe.questID); got != probe.want {
t.Fatalf("%s: resets at the daily boundary=%t, want %t", probe.name, got, probe.want)
}
}
}
// TestCatalogCompletedDailyLeavesAcceptableListForTheRestOfTheServiceDay pins
// the client-visible half of the rule: finishing a daily removes it from the
// acceptable list immediately, and only the 06:00 reset - which clears the row -
// brings it back. It replaces the earlier behaviour where a completed daily was
// re-acceptable at once, which made every daily an unlimited same-day repeat.
func TestCatalogCompletedDailyLeavesAcceptableListForTheRestOfTheServiceDay(t *testing.T) {
catalog := dailyQuestResetTestCatalog(t)
character := CharacterEligibility{Level: 90, Job: 2, GrowType: 0x12}
completedDaily := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
1: {Status: "completed"},
5: {Status: "completed"},
6: {Status: "completed"},
}}
listed := catalog.QuestList(character, completedDaily).IDs
for _, questID := range []int32{1, 5, 6} {
if slices.Contains(listed, questID) {
t.Fatalf("completed daily quest %d is still in the acceptable list", questID)
}
}
// Re-accepting is refused, not merely hidden from the window.
if _, err := catalog.PlanAccept(character, completedDaily, 1); !errors.Is(err, ErrQuestNotAcceptable) {
t.Fatalf("re-accepting a completed daily err=%v, want ErrQuestNotAcceptable", err)
}
// A repeatable quest must stay acceptable after completion.
completedRepeat := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
3: {Status: "completed"},
}}
if !slices.Contains(catalog.QuestList(character, completedRepeat).IDs, int32(3)) {
t.Fatal("a completed normaly-repeat quest disappeared from the acceptable list")
}
// A one-shot quest never returns.
completedEpic := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
4: {Status: "completed"},
}}
if slices.Contains(catalog.QuestList(character, completedEpic).IDs, int32(4)) {
t.Fatal("a completed one-shot quest came back to the acceptable list")
}
}
// TestReconcileDailyResetClearsYesterdaysDailyQuests covers the report that a
// daily finished yesterday stays finished when the server was not running at
// 06:00 - including `daily mission`, which used to never reset at all. The
// reconciliation is driven by the stored completion timestamp rather than by a
// tick, so it catches up however long the process was down.
func TestReconcileDailyResetClearsYesterdaysDailyQuests(t *testing.T) {
ctx := context.Background()
catalog := dailyQuestResetTestCatalog(t)
now := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)
yesterday := now.Add(-24 * time.Hour)
if premium.ServiceDayID(yesterday) >= premium.ServiceDayID(now) {
t.Fatal("probe timestamps are inside the same service day")
}
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "1",
States: map[int64]dnfrepo.QuestState{
1: {Status: "completed", UpdatedAt: yesterday},
2: {Status: "completed", UpdatedAt: now.Add(-10 * time.Minute)},
3: {Status: "active", ProgressValue: 3, UpdatedAt: yesterday},
4: {Status: "completed", UpdatedAt: yesterday},
5: {Status: "completed", UpdatedAt: yesterday},
6: {Status: "completed", UpdatedAt: yesterday},
},
Progress: map[int64]dnfrepo.QuestState{
1: {Status: "completed", ProgressValue: 1, UpdatedAt: yesterday},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.ReconcileDailyReset(ctx, catalog, "1", now)
if err != nil {
t.Fatal(err)
}
if !result.Changed {
t.Fatal("reconcile reported no change")
}
// Quest 1 is cleared once from States and once from Progress, so the sorted
// list carries its id twice.
wantReset := []int64{1, 1, 5, 6}
if !slices.Equal(result.ResetQuestIDs, wantReset) {
t.Fatalf("reset ids=%v, want %v", result.ResetQuestIDs, wantReset)
}
record, found, err := repositories.Quest.Load(ctx, "1")
if err != nil || !found {
t.Fatalf("load quest record found=%t err=%v", found, err)
}
for _, questID := range []int64{1, 5, 6} {
if _, exists := record.States[questID]; exists {
t.Fatalf("yesterday's daily quest %d is still stored", questID)
}
}
if _, exists := record.Progress[1]; exists {
t.Fatal("yesterday's daily kept its progress row")
}
if state, exists := record.States[2]; !exists || state.Status != "completed" {
t.Fatalf("today's daily was reset: %+v", record.States[2])
}
if state, exists := record.States[3]; !exists || state.Status != "active" || state.ProgressValue != 3 {
t.Fatalf("the repeatable quest was reset: %+v", record.States[3])
}
if state, exists := record.States[4]; !exists || state.Status != "completed" {
t.Fatalf("a one-shot quest was reset: %+v", record.States[4])
}
// Running it again on the same day changes nothing.
second, err := owner.ReconcileDailyReset(ctx, catalog, "1", now)
if err != nil || second.Changed {
t.Fatalf("second reconcile changed=%t err=%v", second.Changed, err)
}
}
// TestReconcileDailyResetReturnsYesterdaysDailyMissionToTheAcceptableList is the
// end-to-end guard for the reported defect: a `[daily mission]` behaved as a
// permanent one-shot because no boundary ever cleared its row, while `[daily]`
// behaved as an unlimited same-day repeat because a completed row never blocked
// re-acceptance. Both must now land on the same rule - one completion per
// service day, reset at 06:00.
func TestReconcileDailyResetReturnsYesterdaysDailyMissionToTheAcceptableList(t *testing.T) {
ctx := context.Background()
catalog := dailyQuestResetTestCatalog(t)
character := CharacterEligibility{Level: 90, Job: 2, GrowType: 0x12}
now := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)
yesterday := now.Add(-24 * time.Hour)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "1",
States: map[int64]dnfrepo.QuestState{
1: {Status: "completed", UpdatedAt: yesterday},
5: {Status: "completed", UpdatedAt: yesterday},
6: {Status: "completed", UpdatedAt: yesterday},
},
}); err != nil {
t.Fatal(err)
}
// Before the reset, yesterday's rows still hide all three quests.
stale, found, err := repositories.Quest.Load(ctx, "1")
if err != nil || !found {
t.Fatalf("load quest record found=%t err=%v", found, err)
}
for _, questID := range []int32{1, 5, 6} {
if slices.Contains(catalog.QuestList(character, stale).IDs, questID) {
t.Fatalf("quest %d with a stale row was already acceptable", questID)
}
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
if _, err := owner.ReconcileDailyReset(ctx, catalog, "1", now); err != nil {
t.Fatal(err)
}
reconciled, found, err := repositories.Quest.Load(ctx, "1")
if err != nil || !found {
t.Fatalf("reload quest record found=%t err=%v", found, err)
}
listed := catalog.QuestList(character, reconciled).IDs
for _, questID := range []int32{1, 5, 6} {
if !slices.Contains(listed, questID) {
t.Fatalf("daily quest %d did not return to the acceptable list after the reset", questID)
}
}
}
// TestReconcileDailyResetKeepsRowsWithoutATimestamp protects quest rows that
// predate timestamped states: with no completion time there is no evidence
// they belong to an earlier day, and deleting them would wipe real progress.
func TestReconcileDailyResetKeepsRowsWithoutATimestamp(t *testing.T) {
ctx := context.Background()
source := catalogTestSource{
DefaultList: "1 `daily.qst`\n",
"n_quest/daily.qst": questCatalogTestDefinition("[daily]", 1, 90, "[gunner]", ""),
}
index, err := dnfpvf.Build(ctx, source, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(ctx, index)
if err != nil {
t.Fatal(err)
}
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "1",
States: map[int64]dnfrepo.QuestState{1: {Status: "completed"}},
}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
result, err := owner.ReconcileDailyReset(ctx, catalog, "1", time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC))
if err != nil || result.Changed {
t.Fatalf("untimestamped row was reset: result=%+v err=%v", result, err)
}
}