Files
90CNServer/server/internal/services/dnfbridge/raid_luke_sanctum_maze.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

342 lines
15 KiB
Go
Raw 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 (
"fmt"
"hash/fnv"
"sort"
"time"
)
// Luke phase-one sanctum mazes.
//
// Each sanctum is cleared three times ([5054/5055/5056 DGN HP] 3) and its .dgn
// declares fifteen [maze info] blocks. They form five groups of three
// consecutive mazes -- luke.etc [CARD SET MAP INFO] 0 0 0 3 3 3 6 6 6 9 9 9 12 12
// 12 maps every maze to its group's first maze -- and luke.etc.buff
// [RAID MONSTER LIST] makes the third maze of every group the sanctum's own boss
// (诞生 罗莎乌拉 56673, 湮灭 哈布 56067, 扩散 56066) while the first two carry the
// group's mid bosses and named monsters. The sanctum is meant to be walked as
// one group: the day's group, then its first, second and third maze on the
// first, second and third clear. The group is the day's tarot: it turns over at
// 06:00 server time (runtimeRaidLukeTarotDay), and a raid keeps the group it
// drew even when it runs past 06:00.
//
// 当天那一套牌是**全服一整套**,不是每圣所各抽一根。依据是官方系统说明
// (2017-06-22 国服开放卢克团队模式的更新公告原文):
//
// 「塔罗卡牌以卢克团队模式进行的当日6点为基准进行变更,所有频道的塔罗卡牌
// 统一相同。」
//
// 而 [CARD SET MAP INFO] 正是这一整套牌的表示:**5 条记录、每条 3 个分量**
// (出货数据 `0 0 0 / 3 3 3 / 6 6 6 / 9 9 9 / 12 12 12`;客户端也是每 3 个数字
// 一条地 push 进 3 字节元素的向量,见规格 §8.1.2)。这张表只能表达「三个圣所
// 取同一条记录」,表达不出「诞生走第 0 组 + 湮灭走第 4 组」。所以这里改成:
// **一天抽一条记录(只按 tarot day 哈希,抽法不带圣所)、三个圣所共用该记录、
// 各读自己的分量**(runtimeRaidLukeTarotCardSetRecord +
// runtimeRaidLukeSanctumMazeRecordGroup)。出货数据里每条记录三个分量恒等,
// 所以三个圣所拿到的组起点相同。
//
// ⚠ **未证实(工程约定)**:「第几个分量属于哪个圣所」**没有任何客户端证据**。
// 客户端从不读这 15 个数字(规格 §8.1.2 的指令级扫描只找到 `_Mylast - _Myfirst`
// 这一次计数读,没有任何按 3 字节步长的遍历),所以哪一格属于谁只能靠数据形状
// 推断。这里按 luke.etc [DEFAULT OBJECT COUNT] 的书写顺序取最自然的读法:
// **BIRTH 5054 → 分量 0,EXTINCTION 5055 → 分量 1,SPREAD 5056 → 分量 2**
// (luke.etc.symbol 的 117 [BIRTH SANCTUM OBJECT] / 118 / 119 是同一顺序)。
//
// 记录里的分量若对这个圣所的迷宫数不成立(`start < 0` 或
// `start + 3 > mazeCount`;各圣所的 .dgn 迷宫数可以不同),就退回旧的「可用
// 候选组」抽法(runtimeRaidLukeSanctumMazeGroups),绝不发出越界的 maze。
//
// Picking each entry uniformly from all fifteen mazes let the finale come up on
// the first clear or three times in a row: live 2026-09-24T16:24-16:26Z on
// production, raid 1835030's three 湮灭之圣所 runs drew mazes 8, 11 and 5 --
// all third-of-group -- and fought 哈布 three times.
// runtimeRaidLukeSanctumMazeGroups lists the distinct group starts of [CARD SET
// MAP INFO] that fit a sanctum with mazeCount mazes.
//
// 自 2026-09-29 起它只作**回退**用:正常路径是「当天共用一条记录、各读分量」
// (runtimeRaidLukeSanctumMazeRecordGroup)。这里把 15 个值全部去重,得到的正是
// 每条记录的第一个分量(0/3/6/9/12),也就是旧「各圣所独立抽一根」时代的候选表。
func runtimeRaidLukeSanctumMazeGroups(cardSetMapInfo []int, mazeCount int) []int {
seen := make(map[int]bool, len(cardSetMapInfo))
groups := make([]int, 0, 5)
for _, start := range cardSetMapInfo {
if start < 0 || start+lukeSanctumClearsPerGroup > mazeCount || seen[start] {
continue
}
seen[start] = true
groups = append(groups, start)
}
sort.Ints(groups)
return groups
}
// lukeSanctumClearsPerGroup is how many mazes one group walks: the sanctum's
// [DGN HP].
const lukeSanctumClearsPerGroup = 3
// lukeSanctumCardSetComponents is which component of one [CARD SET MAP INFO]
// record each phase-one sanctum reads.
//
// ⚠ 未证实约定,理由见文件头:客户端从不读这 15 个数字,所以分量归属只能按
// luke.etc [DEFAULT OBJECT COUNT] 的书写顺序(BIRTH 5054 / EXTINCTION 5055 /
// SPREAD 5056 → 0/1/2)取最自然的读法。
var lukeSanctumCardSetComponents = map[int]int{
lukeBirthSanctumMapID: 0,
lukeExtinctionSanctumMapID: 1,
lukeSpreadSanctumMapID: 2,
}
// runtimeRaidLukeTarotCardSetRecords is how many [CARD SET MAP INFO] records the
// day's set is drawn from. The block is a flat value list holding three
// components per record, which is exactly how the client groups it into its
// 3-byte element vector (spec §8.1.2), so the record count is the value count
// divided by three -- 5 for the shipped `0 0 0 3 3 3 6 6 6 9 9 9 12 12 12`.
func runtimeRaidLukeTarotCardSetRecords(cardSetMapInfo []int) int {
return len(cardSetMapInfo) / lukeSanctumClearsPerGroup
}
// runtimeRaidLukeTarotCardSetRecord is the [CARD SET MAP INFO] record the whole
// server walks on one tarot day.
//
// 官方:「塔罗卡牌以卢克团队模式进行的当日6点为基准进行变更,所有频道的塔罗
// 卡牌统一相同。」⇒ 抽取只按 tarot day,**不带圣所、不带攻坚队**:同一天同一台
// 服务器的每一个团、每一个圣所都落在同一条记录上。换日即变。
func runtimeRaidLukeTarotCardSetRecord(raidDay int, records int) int {
if records <= 1 {
return 0
}
hash := fnv.New32a()
_, _ = fmt.Fprintf(hash, "luke-tarot:%d", raidDay)
return int(hash.Sum32() % uint32(records))
}
// runtimeRaidLukeSanctumMazeRecordGroup reads one sanctum's group start out of
// the day's shared [CARD SET MAP INFO] record. ok is false when that component
// does not address a whole group of this sanctum's maze count, in which case the
// caller falls back to the candidate list.
//
// 防御性(不 panic):分量下标越出这个扁平列表时回退到**该记录的第一个分量**
// (未列入 lukeSanctumCardSetComponents 的地牢 ID 同样按第一个分量处理;调用方
// 只会就一阶段三个圣所提问,所以这条只在表被截断时才可能生效)。
func runtimeRaidLukeSanctumMazeRecordGroup(
cardSetMapInfo []int,
record int,
sanctumMapID int,
mazeCount int,
) (int, bool) {
component, known := lukeSanctumCardSetComponents[sanctumMapID]
if !known {
component = 0
}
base := record * lukeSanctumClearsPerGroup
index := base + component
if base < 0 || index >= len(cardSetMapInfo) {
component = 0
index = base
}
if index < 0 || index >= len(cardSetMapInfo) {
return 0, false
}
start := cardSetMapInfo[index]
if start < 0 || start+lukeSanctumClearsPerGroup > mazeCount {
return 0, false
}
return start, true
}
// runtimeRaidLukeSanctumMazeDrawnGroup draws one sanctum's group for one tarot
// day. It reads the day's shared [CARD SET MAP INFO] record first and only
// falls back to the old per-sanctum candidate list when that record cannot
// address a whole group of this sanctum's maze count.
//
// The returned record is the [CARD SET MAP INFO] record the group came from, or
// -1 when the candidate fallback produced it. ok is false when neither path
// yields a usable group: the caller then keeps its ordinary maze choice rather
// than being handed an out-of-range maze. choose must not be nil, and is called
// with "how many choices the day has" -- records first, then, only on the
// fallback, the candidate count.
func runtimeRaidLukeSanctumMazeDrawnGroup(
cardSetMapInfo []int,
mapID int,
mazeCount int,
choose func(int) (int, error),
) (int, int, bool, error) {
records := runtimeRaidLukeTarotCardSetRecords(cardSetMapInfo)
if records > 0 {
record, err := choose(records)
if err != nil {
return 0, -1, false, err
}
if record < 0 || record >= records {
return 0, -1, false, fmt.Errorf("luke sanctum %d card set record %d outside [0,%d)", mapID, record, records)
}
if start, usable := runtimeRaidLukeSanctumMazeRecordGroup(cardSetMapInfo, record, mapID, mazeCount); usable {
return start, record, true, nil
}
}
groups := runtimeRaidLukeSanctumMazeGroups(cardSetMapInfo, mazeCount)
if len(groups) == 0 {
return 0, -1, false, nil
}
position, err := choose(len(groups))
if err != nil {
return 0, -1, false, err
}
if position < 0 || position >= len(groups) {
return 0, -1, false, fmt.Errorf("luke sanctum %d group roll %d outside [0,%d)", mapID, position, len(groups))
}
return groups[position], -1, true, nil
}
type runtimeRaidLukeSanctumMaze struct {
RaidKey uint32
GroupStart int
Clears int
Maze int
Chosen bool
// Record is the [CARD SET MAP INFO] record GroupStart came from, or -1 when
// the group came from the per-sanctum candidate fallback. Every sanctum of
// one tarot day reports the same record.
Record int
}
// runtimeRaidLukeSanctumMazeLocked returns the maze a Luke team raid's next run
// of a phase-one sanctum must use, drawing the sanctum's group on its first
// entry. ok is false for anything that is not such a run, and the caller keeps
// its ordinary maze choice. Caller holds raidMu.
func runtimeRaidLukeSanctumMazeLocked(
state *runtimeRaidState,
mapID int,
mazeCount int,
choose func(int) (int, error),
) (runtimeRaidLukeSanctumMaze, bool, error) {
if state == nil || state.TailFlag == 1 || !state.Started || state.LukeConfig == nil ||
runtimeRaidTeamGraphOwner(*state) != runtimeRaidOwnerLuke ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive ||
state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne {
return runtimeRaidLukeSanctumMaze{}, false, nil
}
if _, sanctum := runtimeRaidLukeSanctumObjectSymbol(mapID); !sanctum {
return runtimeRaidLukeSanctumMaze{}, false, nil
}
groups := runtimeRaidLukeSanctumMazeGroups(state.LukeConfig.CardSetMapInfo, mazeCount)
if len(groups) == 0 {
return runtimeRaidLukeSanctumMaze{}, false, nil
}
result := runtimeRaidLukeSanctumMaze{RaidKey: state.RaidKey, Record: -1}
start, chosen := state.Lifecycle.LukeSanctumMazeGroups[mapID]
if !chosen || start < 0 || start+lukeSanctumClearsPerGroup > mazeCount {
if choose == nil {
return runtimeRaidLukeSanctumMaze{}, false, fmt.Errorf("%w: luke sanctum %d records=%d groups=%d",
errDungeonChoiceRequired, mapID, runtimeRaidLukeTarotCardSetRecords(state.LukeConfig.CardSetMapInfo), len(groups))
}
drawnGroup, record, ok, err := runtimeRaidLukeSanctumMazeDrawnGroup(
state.LukeConfig.CardSetMapInfo, mapID, mazeCount, choose)
if err != nil {
return runtimeRaidLukeSanctumMaze{}, false, err
}
if !ok {
return runtimeRaidLukeSanctumMaze{}, false, nil
}
start = drawnGroup
result.Record = record
if state.Lifecycle.LukeSanctumMazeGroups == nil {
state.Lifecycle.LukeSanctumMazeGroups = make(map[int]int, 3)
}
state.Lifecycle.LukeSanctumMazeGroups[mapID] = start
state.Lifecycle.Revision++
result.Chosen = true
}
clears := 0
if objective, ok := state.Lifecycle.Objectives[mapID]; ok {
clears = objective.ClearCount
}
slot := clears
if slot < 0 {
slot = 0
}
if slot >= lukeSanctumClearsPerGroup {
slot = lukeSanctumClearsPerGroup - 1
}
result.GroupStart = start
result.Clears = clears
result.Maze = start + slot
return result, true, nil
}
// selectRuntimeRaidLukeSanctumMaze is the dungeon-entry side of
// runtimeRaidLukeSanctumMazeLocked.
func (s *Service) selectRuntimeRaidLukeSanctumMaze(
session *gameSession,
mapID int64,
mazeCount int,
) (dungeonMazeSelection, bool, error) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return dungeonMazeSelection{}, false, nil
}
raidDay := runtimeRaidLukeTarotDay(s.gameplayNow())
// 官方「所有频道的塔罗卡牌统一相同」⇒ 当天那一套牌只由 tarot day 决定,抽取器
// 不带圣所:三个圣所落在同一条 [CARD SET MAP INFO] 记录上、各读自己的分量。
// 回退路径复用同一个「当天第 k 个」抽取器(各圣所迷宫数不同时候选表会不同,
// 那是回退路径唯一可能的差异,也是它存在的理由)。
daily := func(limit int) (int, error) {
return runtimeRaidLukeTarotCardSetRecord(raidDay, limit), nil
}
s.raidMu.Lock()
state := s.runtimeRaidByMemberLocked(session.selectedCharacterID)
// 记录条数在读锁内取:解锁后 state 可能被并发改写(例如攻坚释放时
// LukeConfig 被置空),只有 maze 这个值副本可以带出锁。
cardSetRecords := 0
if state != nil && state.LukeConfig != nil {
cardSetRecords = runtimeRaidLukeTarotCardSetRecords(state.LukeConfig.CardSetMapInfo)
}
maze, ok, err := runtimeRaidLukeSanctumMazeLocked(state, int(mapID), mazeCount, daily)
s.raidMu.Unlock()
if err != nil || !ok {
return dungeonMazeSelection{}, false, err
}
s.logGameEvent(session, "game-raid-luke-sanctum-maze",
"char_id", session.selectedCharacterID,
"raid_key", maze.RaidKey,
"sanctum", mapID,
"tarot_day", raidDay,
"card_set_record", maze.Record,
"card_set_records", cardSetRecords,
"group_start", maze.GroupStart,
"group_chosen_now", maze.Chosen,
"clears", maze.Clears,
"maze_index", maze.Maze)
return dungeonMazeSelection{Index: maze.Maze, Reason: "luke_sanctum_card_set_group"}, true, nil
}
// lukeTarotRefreshHour is when the tarot cards turn over: "每个卢克RAID攻坚日
// 早上6点会刷新塔罗牌,第一阶段的怪物分布和地图机制均按照塔罗牌显示安排"
// (2017-06 / 2018-05 CN guides). Server local time.
const lukeTarotRefreshHour = 6
// runtimeRaidLukeTarotDay is the tarot day a moment belongs to, as YYYYMMDD in
// server local time: 05:59 still reads as the previous day's cards.
func runtimeRaidLukeTarotDay(now time.Time) int {
day := now.Local().Add(-lukeTarotRefreshHour * time.Hour)
return day.Year()*10000 + int(day.Month())*100 + day.Day()
}
// runtimeRaidLukeTarotGroup picks one sanctum's index into a per-sanctum list of
// size `groups` for a tarot day: every raid on the server draws the same index
// on the same day, and the sanctums draw independently of each other.
//
// 它现在只服务**图案序号 B**(卢克塔罗牌 1181 记录里每个圣所"今天启动哪套
// 防御体系",各圣所的 `[PATTERN]` 段数不同:5054/5056 两段、5055 四段)。
// 「走哪组迷宫」自 2026-09-29 起不再用它,改用只按天、三所共用的
// runtimeRaidLukeTarotCardSetRecord。
func runtimeRaidLukeTarotGroup(raidDay int, sanctumMapID int, groups int) int {
if groups <= 1 {
return 0
}
hash := fnv.New32a()
_, _ = fmt.Fprintf(hash, "luke-tarot:%d:%d", raidDay, sanctumMapID)
return int(hash.Sum32() % uint32(groups))
}