Files
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

363 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"errors"
"reflect"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/raid"
)
// lukeCardSetMapInfo is luke.etc's [CARD SET MAP INFO], verbatim.
var lukeCardSetMapInfo = []int{0, 0, 0, 3, 3, 3, 6, 6, 6, 9, 9, 9, 12, 12, 12}
func lukeSanctumMazeTestState() *runtimeRaidState {
state := lukePhaseOneGeneratorState()
state.LukeConfig.CardSetMapInfo = append([]int(nil), lukeCardSetMapInfo...)
state.Lifecycle.Objectives = map[int]runtimeRaidDungeonObjective{}
for _, mapID := range []int{lukeBirthSanctumMapID, lukeExtinctionSanctumMapID, lukeSpreadSanctumMapID} {
state.Lifecycle.Objectives[mapID] = runtimeRaidDungeonObjective{
Phase: runtimeRaidDungeonPhaseOne, MapID: mapID, State: raid.AntonDungeonOpen, MaxHP: 3, RemainingHP: 3,
}
}
return state
}
// 这是**回退**候选表:正常路径已改成「当天共用一条记录、各读分量」,只有记录的分量
// 放不下该圣所的迷宫数时才回到这里。它对 "旧行为" 的钉法照旧有效。
func TestLukeSanctumMazeGroupsComeFromCardSetMapInfo(t *testing.T) {
if got := runtimeRaidLukeSanctumMazeGroups(lukeCardSetMapInfo, 15); !reflect.DeepEqual(got, []int{0, 3, 6, 9, 12}) {
t.Fatalf("groups=%v want [0 3 6 9 12]", got)
}
// A group whose third maze does not exist is not a group.
if got := runtimeRaidLukeSanctumMazeGroups(lukeCardSetMapInfo, 13); !reflect.DeepEqual(got, []int{0, 3, 6, 9}) {
t.Fatalf("groups for 13 mazes=%v want [0 3 6 9]", got)
}
}
// Live 2026-09-24 production: three uniform picks of 湮灭之圣所 drew mazes 8, 11
// and 5, all third-of-group, and the squad fought 哈布 three times. The sanctum
// must instead walk one drawn group's first, second and third maze.
//
// ⚠ 语义变更(2026-09-29):这个测试里的 choose 以前是「在候选组列表里抽一个下标、
// 各圣所各抽各的」,现在是「**当天抽哪一条 [CARD SET MAP INFO] 记录**」——官方
// 「所有频道的塔罗卡牌统一相同」⇒ 三圣所共用同一条记录。所以下面那条
// "Every sanctum draws its own group" 已按新语义换成"三所共用当天那条记录"。
// 其余部分(一次抽完不再抽、给放弃不重复抽、新一阶段重新抽)逐字保留。
func TestLukeSanctumWalksItsDrawnGroupOneMazePerClear(t *testing.T) {
state := lukeSanctumMazeTestState()
rolls := 0
choose := func(limit int) (int, error) {
rolls++
if limit != 5 {
t.Fatalf("card set record roll limit=%d want 5 records", limit)
}
return 3, nil // 记录 3 = `9 9 9`
}
for clear, wantMaze := range []int{9, 10, 11} {
objective := state.Lifecycle.Objectives[lukeExtinctionSanctumMapID]
objective.ClearCount = clear
state.Lifecycle.Objectives[lukeExtinctionSanctumMapID] = objective
maze, ok, err := runtimeRaidLukeSanctumMazeLocked(state, lukeExtinctionSanctumMapID, 15, choose)
if err != nil || !ok {
t.Fatalf("clear %d: ok=%t err=%v", clear, ok, err)
}
if maze.Maze != wantMaze || maze.GroupStart != 9 || maze.Chosen != (clear == 0) {
t.Fatalf("clear %d: maze=%+v want maze %d of group 9", clear, maze, wantMaze)
}
}
// A give-up does not clear: the retry re-enters the same maze.
if maze, _, _ := runtimeRaidLukeSanctumMazeLocked(state, lukeExtinctionSanctumMapID, 15, choose); maze.Maze != 11 {
t.Fatalf("retry after the third clear's give-up=%d want 11", maze.Maze)
}
if rolls != 1 {
t.Fatalf("group rolled %d times want once per sanctum", rolls)
}
// 当天那条记录由三个圣所共用(旧断言是"每圣所各抽自己的组",已随 2026-09-29
// 的口径改动替换):同一个记录下标下,另外两个圣所读到的正是同一条记录的分量。
// 出货数据每条记录三个分量恒等,所以组起点相同。
for _, sanctum := range []int{lukeBirthSanctumMapID, lukeSpreadSanctumMapID} {
maze, ok, err := runtimeRaidLukeSanctumMazeLocked(state, sanctum, 15, choose)
if err != nil || !ok {
t.Fatalf("sanctum %d: ok=%t err=%v", sanctum, ok, err)
}
if maze.Record != 3 || maze.GroupStart != 9 || maze.Maze != 9 {
t.Fatalf("sanctum %d maze=%+v want the day's record 3 (= group 9)", sanctum, maze)
}
}
if got := state.Lifecycle.LukeSanctumMazeGroups[lukeExtinctionSanctumMapID]; got != 9 {
t.Fatalf("another sanctum's draw moved the extinction group to %d", got)
}
// 换一天(换一条记录)就换一组:记录 0 = `0 0 0`。用一个还没抽过的团验证。
fresh := lukeSanctumMazeTestState()
other := func(int) (int, error) { return 0, nil }
if maze, ok, _ := runtimeRaidLukeSanctumMazeLocked(fresh, lukeBirthSanctumMapID, 15, other); !ok ||
maze.Record != 0 || maze.GroupStart != 0 || maze.Maze != 0 {
t.Fatalf("birth sanctum maze=%+v ok=%t want record 0 / group 0", maze, ok)
}
// A new phase one draws again.
initRuntimeRaidLukePhaseOneLocked(state)
if len(state.Lifecycle.LukeSanctumMazeGroups) != 0 {
t.Fatalf("phase-one init kept groups %v", state.Lifecycle.LukeSanctumMazeGroups)
}
}
func TestLukeSanctumMazeLeavesEveryOtherRunToTheOrdinaryChoice(t *testing.T) {
failing := func(int) (int, error) { return 0, errors.New("must not roll") }
state := lukeSanctumMazeTestState()
if _, ok, err := runtimeRaidLukeSanctumMazeLocked(state, lukeLightAltarMapID, 15, failing); ok || err != nil {
t.Fatalf("a phase-two map took the sanctum path ok=%t err=%v", ok, err)
}
state.Lifecycle.Phase = runtimeRaidDungeonPhaseTwo
if _, ok, err := runtimeRaidLukeSanctumMazeLocked(state, lukeBirthSanctumMapID, 15, failing); ok || err != nil {
t.Fatalf("phase two took the sanctum path ok=%t err=%v", ok, err)
}
state.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
state.TailFlag = 1
if _, ok, err := runtimeRaidLukeSanctumMazeLocked(state, lukeBirthSanctumMapID, 15, failing); ok || err != nil {
t.Fatalf("a single-mode raid took the sanctum path ok=%t err=%v", ok, err)
}
if _, ok, err := runtimeRaidLukeSanctumMazeLocked(nil, lukeBirthSanctumMapID, 15, failing); ok || err != nil {
t.Fatalf("no raid took the sanctum path ok=%t err=%v", ok, err)
}
}
// TestLukeSanctumMazeSharesTheDaysCardSetRecordAcrossSanctums is the state-level
// pin for the 2026-09-29 rule change. Official system text (2017-06-22 国服开放
// 卢克团队模式): 「塔罗卡牌以卢克团队模式进行的当日6点为基准进行变更,所有频道
// 的塔罗卡牌统一相同。」⇒ 一天抽**一条** [CARD SET MAP INFO] 记录,三个圣所共用
// 它、各读自己的分量;抽取器只按 tarot day,不带圣所。
//
// 用生产侧那一个抽取器跑一整个月:任一天里三个圣所必须报出同一个 Record,且
// 组起点就是该记录的分量;跨天至少出现 3 条不同记录(否则"每天变"是假的)。
func TestLukeSanctumMazeSharesTheDaysCardSetRecordAcrossSanctums(t *testing.T) {
seen := map[int]bool{}
for day := 1; day <= 30; day++ {
raidDay := 20260900 + day
// 生产侧 selectRuntimeRaidLukeSanctumMaze 给的就是这一个:只按 tarot day。
draw := func(limit int) (int, error) {
return runtimeRaidLukeTarotCardSetRecord(raidDay, limit), nil
}
record := runtimeRaidLukeTarotCardSetRecord(raidDay, 5)
if record < 0 || record >= 5 {
t.Fatalf("day %d record=%d outside [0,5)", raidDay, record)
}
seen[record] = true
state := lukeSanctumMazeTestState()
for _, sanctum := range []int{lukeBirthSanctumMapID, lukeExtinctionSanctumMapID, lukeSpreadSanctumMapID} {
maze, ok, err := runtimeRaidLukeSanctumMazeLocked(state, sanctum, 15, draw)
if err != nil || !ok {
t.Fatalf("day %d sanctum %d ok=%t err=%v", raidDay, sanctum, ok, err)
}
if maze.Record != record {
t.Fatalf("day %d sanctum %d read record %d, the day drew %d", raidDay, sanctum, maze.Record, record)
}
// 出货数据每条记录三个分量恒等 ⇒ 三所组起点都等于该记录的分量。
want := lukeCardSetMapInfo[record*lukeSanctumClearsPerGroup]
if maze.GroupStart != want || maze.Maze != want {
t.Fatalf("day %d sanctum %d maze=%+v want group start %d on the first clear",
raidDay, sanctum, maze, want)
}
}
}
if len(seen) < 3 {
t.Fatalf("a month of tarot days drew only card set records %v", seen)
}
}
// TestLukeSanctumCardSetComponentsMapEachSanctumToItsOwnCell uses a table whose
// records carry three DIFFERENT components, so "each sanctum reads its own cell"
// is distinguishable from "all three read the first cell". The mapping itself
// (BIRTH 0 / EXTINCTION 1 / SPREAD 2) is an unproven convention taken from
// luke.etc [DEFAULT OBJECT COUNT]'s written order -- see the file header.
func TestLukeSanctumCardSetComponentsMapEachSanctumToItsOwnCell(t *testing.T) {
distinct := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}
draw := func(int) (int, error) { return 2, nil } // 记录 2 = `6 7 8`
want := map[int]int{
lukeBirthSanctumMapID: 6,
lukeExtinctionSanctumMapID: 7,
lukeSpreadSanctumMapID: 8,
}
state := lukeSanctumMazeTestState()
state.LukeConfig.CardSetMapInfo = distinct
for sanctum, wantGroup := range want {
// mazeCount 取 60:这 15 个分量都是合法组起点,所以走的是共用记录这条路径。
maze, ok, err := runtimeRaidLukeSanctumMazeLocked(state, sanctum, 60, draw)
if err != nil || !ok {
t.Fatalf("sanctum %d ok=%t err=%v", sanctum, ok, err)
}
if maze.Record != 2 || maze.GroupStart != wantGroup || maze.Maze != wantGroup {
t.Fatalf("sanctum %d maze=%+v want record 2 / group %d", sanctum, maze, wantGroup)
}
}
}
// TestLukeSanctumMazeFallsBackWhenTheDaysRecordDoesNotFit keeps the guard the
// change must not lose: the shared record's component still has to address a
// whole group of THIS sanctum's maze count (each .dgn declares its own), and a
// group that does not fit falls back to the candidate list instead of emitting
// an out-of-range maze. An unusable table takes over nothing at all.
func TestLukeSanctumMazeFallsBackWhenTheDaysRecordDoesNotFit(t *testing.T) {
// 记录 1 = `14 14 14`;迷宫数 15 容不下 14(14+3 > 15)。候选表只剩 {12}。
tight := []int{12, 12, 12, 14, 14, 14}
// 抽取器对两次调用都要给出合法值:记录数 2 → 1;候选表大小 1 → 0。
draw := func(limit int) (int, error) { return limit - 1, nil }
state := lukeSanctumMazeTestState()
state.LukeConfig.CardSetMapInfo = tight
maze, ok, err := runtimeRaidLukeSanctumMazeLocked(state, lukeBirthSanctumMapID, 15, draw)
if err != nil || !ok {
t.Fatalf("ok=%t err=%v", ok, err)
}
if maze.Record != -1 {
t.Fatalf("record=%d want -1 (the candidate fallback drew this one)", maze.Record)
}
if maze.GroupStart != 12 || maze.Maze != 12 {
t.Fatalf("maze=%+v want the only candidate group 12", maze)
}
if maze.Maze+lukeSanctumClearsPerGroup > 15 {
t.Fatalf("maze=%d runs past the sanctum's 15 mazes", maze.Maze)
}
// 候选表为空时宁可不接管这一次选图,也不发越界 maze。
none := lukeSanctumMazeTestState()
none.LukeConfig.CardSetMapInfo = []int{14, 14, 14}
if maze, ok, err := runtimeRaidLukeSanctumMazeLocked(none, lukeBirthSanctumMapID, 15, draw); ok || err != nil {
t.Fatalf("an unusable table took the sanctum path maze=%+v ok=%t err=%v", maze, ok, err)
}
// 越界的记录下标是错误,不是静默当成第 0 条。
outOfRange := func(int) (int, error) { return 9, nil }
if _, _, ok, err := runtimeRaidLukeSanctumMazeDrawnGroup(lukeCardSetMapInfo, lukeBirthSanctumMapID, 15, outOfRange); ok || err == nil {
t.Fatalf("record roll 9 ok=%t err=%v want an error", ok, err)
}
}
// TestLukeSanctumMazeRecordGroupReadsTheSharedRecordAndIsDefensive pins the
// record→group reader on its own: the shipped table, a distinct-component table,
// an unknown dungeon id, and the defensive paths (out-of-range record, truncated
// table) that must fall back to the record's first component instead of
// panicking.
func TestLukeSanctumMazeRecordGroupReadsTheSharedRecordAndIsDefensive(t *testing.T) {
for record := 0; record < 5; record++ {
for _, sanctum := range []int{lukeBirthSanctumMapID, lukeExtinctionSanctumMapID, lukeSpreadSanctumMapID} {
start, ok := runtimeRaidLukeSanctumMazeRecordGroup(lukeCardSetMapInfo, record, sanctum, 15)
if !ok || start != record*lukeSanctumClearsPerGroup {
t.Fatalf("record %d sanctum %d start=%d ok=%t want %d",
record, sanctum, start, ok, record*lukeSanctumClearsPerGroup)
}
}
}
distinct := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}
for component, sanctum := range map[int]int{
0: lukeBirthSanctumMapID,
1: lukeExtinctionSanctumMapID,
2: lukeSpreadSanctumMapID,
} {
start, ok := runtimeRaidLukeSanctumMazeRecordGroup(distinct, 2, sanctum, 60)
if !ok || start != 6+component {
t.Fatalf("sanctum %d component %d start=%d ok=%t want %d", sanctum, component, start, ok, 6+component)
}
}
// 未列入映射的地牢 ID 按该记录的第一个分量处理(防御性;调用方只会问三个圣所)。
if start, ok := runtimeRaidLukeSanctumMazeRecordGroup(distinct, 2, 12345, 60); !ok || start != 6 {
t.Fatalf("unknown sanctum start=%d ok=%t want 6", start, ok)
}
// 越界的记录下标不 panic,只是不成立。
if start, ok := runtimeRaidLukeSanctumMazeRecordGroup(lukeCardSetMapInfo, 99, lukeBirthSanctumMapID, 15); ok {
t.Fatalf("record 99 start=%d ok=%t want not ok", start, ok)
}
if _, ok := runtimeRaidLukeSanctumMazeRecordGroup(lukeCardSetMapInfo, -1, lukeBirthSanctumMapID, 15); ok {
t.Fatal("a negative record was accepted")
}
// 表被截断(16 个值 = 5 条记录 + 1):分量 2 越出列表 ⇒ 回退到该记录的第一个
// 分量,不 panic。
truncated := append(append([]int(nil), lukeCardSetMapInfo...), 15)
if start, ok := runtimeRaidLukeSanctumMazeRecordGroup(truncated, 5, lukeSpreadSanctumMapID, 18); !ok || start != 15 {
t.Fatalf("truncated table start=%d ok=%t want the record's first component 15", start, ok)
}
// 组起点放不下这个圣所的迷宫数 ⇒ 不成立,交给回退。
if start, ok := runtimeRaidLukeSanctumMazeRecordGroup(lukeCardSetMapInfo, 4, lukeBirthSanctumMapID, 13); ok {
t.Fatalf("group start %d was accepted for a 13-maze sanctum", start)
}
// 出货数据下「共用记录、各读分量」与旧「每圣所各取同一分量」在**记录→组起点**
// 这一层逐值等价:记录 i 的三个分量恒等,且正是旧候选表里的第 i 项。行为差异因此
// 只剩"三所抽的是不是同一条记录"这一点,也就是本次要改的那一点。
groups := runtimeRaidLukeSanctumMazeGroups(lukeCardSetMapInfo, 15)
if len(groups) != 5 {
t.Fatalf("candidate groups=%v want 5 records", groups)
}
for record := range groups {
for _, sanctum := range []int{lukeBirthSanctumMapID, lukeExtinctionSanctumMapID, lukeSpreadSanctumMapID} {
start, ok := runtimeRaidLukeSanctumMazeRecordGroup(lukeCardSetMapInfo, record, sanctum, 15)
if !ok || start != groups[record] {
t.Fatalf("record %d sanctum %d start=%d ok=%t want the old candidate %d",
record, sanctum, start, ok, groups[record])
}
}
}
}
// The tarot turns over at 06:00 server time and is the same for every raid that
// day ("每个卢克RAID攻坚日早上6点会刷新塔罗牌").
//
// 这段钉的是**每圣所的图案序号 B**(1181 里"该圣所今天启动哪套防御体系"),
// 2026-09-29 的改动没有动它的选法;"当天走哪组迷宫"改成了三所共用一条
// [CARD SET MAP INFO] 记录,由上面 TestLukeSanctumMazeSharesTheDaysCardSetRecord
// AcrossSanctums 钉住。
func TestLukeTarotDayTurnsOverAtSixAndIsSharedByTheServer(t *testing.T) {
local := time.Local
before := time.Date(2026, 9, 26, 5, 59, 0, 0, local)
after := time.Date(2026, 9, 26, 6, 0, 0, 0, local)
if got := runtimeRaidLukeTarotDay(before); got != 20260925 {
t.Fatalf("05:59 tarot day=%d want the previous day 20260925", got)
}
if got := runtimeRaidLukeTarotDay(after); got != 20260926 {
t.Fatalf("06:00 tarot day=%d want 20260926", got)
}
// Same day, same sanctum: every raid draws the same pattern index for it.
for _, sanctum := range []int{lukeBirthSanctumMapID, lukeExtinctionSanctumMapID, lukeSpreadSanctumMapID} {
first := runtimeRaidLukeTarotGroup(20260926, sanctum, 5)
if first < 0 || first >= 5 {
t.Fatalf("sanctum %d pattern index=%d outside [0,5)", sanctum, first)
}
if again := runtimeRaidLukeTarotGroup(20260926, sanctum, 5); again != first {
t.Fatalf("sanctum %d drew %d then %d on one day", sanctum, first, again)
}
}
// Across a month the draw actually changes.
seen := map[int]bool{}
for day := 1; day <= 30; day++ {
seen[runtimeRaidLukeTarotGroup(20260900+day, lukeExtinctionSanctumMapID, 5)] = true
}
if len(seen) < 3 {
t.Fatalf("a month of tarot days drew only pattern indices %v", seen)
}
// 当天的记录抽取器(走哪组迷宫)同样按天统一、换日即变,且不随圣所变。
shared := map[int]int{}
for day := 1; day <= 30; day++ {
record := runtimeRaidLukeTarotCardSetRecord(20260900+day, 5)
if record < 0 || record >= 5 {
t.Fatalf("day %d card set record=%d outside [0,5)", day, record)
}
shared[record] = day
}
if len(shared) < 3 {
t.Fatalf("a month of tarot days drew only card set records %v", shared)
}
if runtimeRaidLukeTarotCardSetRecord(20260926, 5) != runtimeRaidLukeTarotCardSetRecord(20260926, 5) {
t.Fatal("the card set record draw is not reproducible on one day")
}
// 只有一条记录(或没有)时定格在 0,不 panic。
for _, records := range []int{0, 1, -3} {
if got := runtimeRaidLukeTarotCardSetRecord(20260926, records); got != 0 {
t.Fatalf("records=%d record=%d want 0", records, got)
}
}
}