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,334 @@
package dnfbridge
import (
"strconv"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
"longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
// 攻坚队的「地下城通关次数」是**队伍(攻坚队分组)通关次数**,不是「有几个队员打完了」。
// 黑雾之源(210)要 4 次通关才把关卡打掉、推进下一阶段;一次组队进图(一个 run)无论进去
// 几个人,都只能算 1 次。
//
// 现场(2026-09-19 组队实测):两人组队通关两把就进下一阶段了;按这个算法四人组队一把就能
// 直接过。原因见 applyRuntimeRaidJoinedSquadClear 的注释:本服给每个队员各自一份房间实例
// (各自的 seed),于是同一次进图里每个队员的击杀都被当成一次独立的通关,各自扣一点关卡 HP。
//
// 真机口径(86JP RaidManager.TryClearDungeon / RaidDungeonParticipation):一个
// (dungeon, party) 只有一条参与记录,通关计数是 (raid, dungeon) 上的标量,所以一个 run 只记
// 一次;队员个人列显示的是**本队**的计数(TryClearDungeon 给全队每个队员都加 1)。
func TestRuntimeRaidPartyRunSpendsOneClearHoweverManyMembersCleared(t *testing.T) {
fixture := newRuntimeRaidPartyClearTestFixture(t, 4)
owner := fixture.enter(t, 1001, 11)
second := fixture.enter(t, 1002, 22)
third := fixture.enter(t, 1003, 33)
fourth := fixture.enter(t, 1004, 44)
// 同一队的后进者沿用开团者的 binding:一个 run,一条记录。
for _, runtime := range []*runtimeDungeonState{second, third, fourth} {
if runtime.raidDungeon.LifecycleToken != owner.raidDungeon.LifecycleToken {
t.Fatalf("a squadmate opened its own run: %+v want token %d",
runtime.raidDungeon, owner.raidDungeon.LifecycleToken)
}
}
for _, characterID := range []uint16{1001, 1002, 1003, 1004} {
fixture.complete(t, characterID)
}
objective := fixture.objective(t, antonBlackFogSourceMapID)
if objective.RemainingHP != 3 || objective.ClearCount != 1 {
t.Fatalf("four members clearing one run spent %d gate points: %+v want exactly one",
objective.ClearCount, objective)
}
if objective.State != raid.AntonDungeonOpen {
t.Fatalf("the gate left the open state after a single party run: %+v", objective)
}
// 队员个人列读到的是本队的计数,不能因为四个人打同一把就翻四倍。
if counts := fixture.clearCounts(t); counts[1001] != 1 || counts[1004] != 1 {
t.Fatalf("member clear counts=%+v want the squad's single clear", counts)
}
// 第二把(同一个队、新的 run token)才扣第二点。
fixture.enter(t, 1001, 12)
fixture.complete(t, 1001)
if got := fixture.objective(t, antonBlackFogSourceMapID); got.RemainingHP != 2 || got.ClearCount != 2 {
t.Fatalf("the squad's second run=%+v want the second gate point", got)
}
}
// 一队四个人打一把 = 1 次;所以四人队也要打满四把才推阶段,不能一把过。
func TestRuntimeRaidFourMemberPartyNeedsFourRunsToClearTheGate(t *testing.T) {
fixture := newRuntimeRaidPartyClearTestFixture(t, 4)
gate := antonBlackFogSourceMapID
for run := 1; run <= 4; run++ {
for _, characterID := range []uint16{1001, 1002, 1003, 1004} {
fixture.enter(t, characterID, uint64(run)*10+uint64(characterID-1000))
}
for _, characterID := range []uint16{1001, 1002, 1003, 1004} {
fixture.complete(t, characterID)
}
}
objective := fixture.objective(t, gate)
if objective.RemainingHP != 0 || objective.ClearCount != 4 {
t.Fatalf("four four-member runs=%+v want the gate cleared after four party runs", objective)
}
// 打空的 210 由一阶段机制藏起来(黑雾散开),和 raid_anton_phase_one_test.go 里
// 「四次通关后 hide」的既有口径一致。
if objective.State != raid.AntonDungeonHide {
t.Fatalf("a finished black-fog source must be hidden by the phase mechanism: %+v", objective)
}
}
// 顺序反过来也一样:队员先在自己那份实例里打完,队长后打完,整把仍然只算 1 次。
func TestRuntimeRaidSquadmateClearBeforeTheOwnerStillSpendsOnePoint(t *testing.T) {
fixture := newRuntimeRaidPartyClearTestFixture(t, 2)
ownerRuntime := fixture.enter(t, 1001, 11)
squadmateRuntime := fixture.enter(t, 1002, 22)
if squadmateRuntime.raidDungeon.LifecycleToken != ownerRuntime.raidDungeon.LifecycleToken {
t.Fatalf("fixture: the squadmate opened its own run: %+v", squadmateRuntime.raidDungeon)
}
fixture.joinedClear(t, 1002)
if got := fixture.objective(t, antonBlackFogSourceMapID); got.RemainingHP != 3 || got.ClearCount != 1 {
t.Fatalf("the squadmate's own instance=%+v want the run's single point", got)
}
fixture.completeOwner(t, 1001)
got := fixture.objective(t, antonBlackFogSourceMapID)
if got.RemainingHP != 3 || got.ClearCount != 1 {
t.Fatalf("the owner's completion of the same run spent a second point: %+v", got)
}
if runs := fixture.openRunCount(t); runs != 0 {
t.Fatalf("the owner's completion did not close the run: %d open", runs)
}
}
// 守卫:不同攻坚队分组各打各的 run,各算一次通关。两个未分组(GroupIndex 0)的成员各自开一
// 个 run,所以两次通关必须各扣一点——「一个 run 一次」不能被读成「一个攻坚队一次」。
func TestRuntimeRaidSeparatePartiesStillSpendTheirOwnClear(t *testing.T) {
fixture := newRuntimeRaidPartyClearTestFixture(t, 0)
fixture.enter(t, 1001, 11)
fixture.complete(t, 1001)
if got := fixture.objective(t, antonBlackFogSourceMapID); got.RemainingHP != 3 || got.ClearCount != 1 {
t.Fatalf("the first unassigned member's own run=%+v want one point", got)
}
fixture.enter(t, 1002, 22)
fixture.complete(t, 1002)
if got := fixture.objective(t, antonBlackFogSourceMapID); got.RemainingHP != 2 || got.ClearCount != 2 {
t.Fatalf("the second unassigned member's own run=%+v want its own point", got)
}
}
// 同一把里的重放报告(op39/op117 重发、队员重复报完成)一分不扣。
func TestRuntimeRaidReplayedPartyRunClearEarnsNothing(t *testing.T) {
fixture := newRuntimeRaidPartyClearTestFixture(t, 2)
fixture.enter(t, 1001, 11)
fixture.enter(t, 1002, 22)
fixture.complete(t, 1001)
fixture.complete(t, 1002)
fixture.complete(t, 1002)
fixture.complete(t, 1001)
if got := fixture.objective(t, antonBlackFogSourceMapID); got.RemainingHP != 3 || got.ClearCount != 1 {
t.Fatalf("a replayed report spent another point: %+v", got)
}
}
// runtimeRaidPartyClearTestFixture drives real raid gate runs: the first member into
// the gate opens the run, every later member of the same squad adopts that binding,
// and each one reports its own instance's completion from its own session.
//
// Every member gets its own live dungeon session on purpose. The raid's stale-gate
// sweep (releaseRuntimeRaidStaleGateOccupancy) releases any run whose owning session
// no longer holds an active dungeon, so a fixture without real sessions would have
// its open runs swept the moment one member's clear publishes objective progress --
// which is exactly the state the joined-clear ordering has to be judged in.
type runtimeRaidPartyClearTestFixture struct {
t *testing.T
service *Service
raidKey uint32
config *raid.AntonConfig
topology *worldmap.DungeonTopology
runs map[uint16]*runtimeDungeonState
}
func newRuntimeRaidPartyClearTestFixture(t *testing.T, groupIndex byte) runtimeRaidPartyClearTestFixture {
t.Helper()
queue := newFakeCurrentDungeonDeathTimerQueue()
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
gameSessions: make(map[uint16]*gameSession),
gameplayTimers: queue,
}
_, resolver, _ := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
topology, err := worldmap.BuildDungeonLayout(
resolver,
bridgeRaidPartyClearDungeonID,
0,
func(choice worldmap.DungeonMapChoice) (int64, error) {
if len(choice.Candidates) == 0 {
return 0, worldmap.ErrMapChoiceRequired
}
return choice.Candidates[0].ID, nil
},
)
if err != nil {
t.Fatalf("build a live dungeon layout for the fixture: %v", err)
}
config := antonPhaseOneTestConfig()
lifecycle := newRuntimeRaidLifecycleState(queue.Now())
if err := initializeRuntimeRaidPhaseObjectives(&lifecycle, config, runtimeRaidDungeonPhaseOne); err != nil {
t.Fatalf("initialize phase one: %v", err)
}
lifecycle.Status = runtimeRaidLifecyclePhaseActive
lifecycle.Phase = runtimeRaidDungeonPhaseOne
lifecycle.PhaseDeadline = queue.Now().Add(40 * time.Minute)
members := make([]runtimeRaidMemberState, 0, 4)
for index, characterID := range []uint16{1001, 1002, 1003, 1004} {
members = append(members, runtimeRaidMemberState{
CharID: characterID, GroupIndex: groupIndex, SlotOrder: byte(index),
})
}
const raidKey = uint32(0x00c80700)
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
TailFlag: 5,
AntonConfig: config,
Started: true,
EntryCostPaid: true,
Members: members,
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
Lifecycle: lifecycle,
}
return runtimeRaidPartyClearTestFixture{
t: t, service: service, raidKey: raidKey, config: config, topology: topology,
runs: make(map[uint16]*runtimeDungeonState),
}
}
// bridgeRaidPartyClearDungeonID is the bridge-PVF dungeon whose layout gives every
// member a real active run to hold. The raid gate identity comes from the request
// (210), which is what beginRuntimeRaidDungeon maps.
const bridgeRaidPartyClearDungeonID int64 = 700
// enter freezes one member's gate selection in its own session, exactly like a real
// op16 into the raid gate does: its own dungeon instance of the gate, its own
// lifecycle token, and the squad's shared run binding.
func (f runtimeRaidPartyClearTestFixture) enter(
t *testing.T,
characterID uint16,
token uint64,
) *runtimeDungeonState {
t.Helper()
run, err := worldmap.NewDungeonRun(f.topology)
if err != nil {
t.Fatalf("char %d dungeon run: %v", characterID, err)
}
instance, err := worldmap.NewDungeonSession(run)
if err != nil {
t.Fatalf("char %d dungeon session: %v", characterID, err)
}
runtime := &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: antonBlackFogSourceMapID},
Character: dnfrepo.CharacterRecord{CharacterID: strconv.Itoa(int(characterID))},
lifecycleToken: token,
Session: instance,
}
session := &gameSession{
conn: &bufferConn{},
connID: "raid-party-clear-" + strconv.Itoa(int(characterID)),
selectedCharacterID: characterID,
characterGeneration: 1,
dungeon: dungeonSessionState{runtime: runtime},
}
if err := f.service.freezeRuntimeRaidDungeonSelection(session, runtime); err != nil {
t.Fatalf("char %d entry: %v", characterID, err)
}
if err := f.service.notifyRuntimeRaidDungeonLoaded(session); err != nil {
t.Fatalf("char %d loaded: %v", characterID, err)
}
f.service.mu.Lock()
f.service.gameSessions[characterID] = session
f.service.mu.Unlock()
f.runs[characterID] = runtime
return runtime
}
// complete reports one member's own instance of the run as cleared.
func (f runtimeRaidPartyClearTestFixture) complete(t *testing.T, characterID uint16) {
t.Helper()
session, runtime := f.sessionFor(t, characterID)
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if err := f.service.notifyRuntimeRaidDungeonCompletedLocked(session, runtime); err != nil {
t.Fatalf("char %d completed: %v", characterID, err)
}
}
// joinedClear reports the same completion through the adopting squadmate's own path:
// the run belongs to the opener, so the squadmate only books its own clear.
func (f runtimeRaidPartyClearTestFixture) joinedClear(t *testing.T, characterID uint16) {
t.Helper()
session, runtime := f.sessionFor(t, characterID)
f.service.applyRuntimeRaidJoinedSquadClear(session, runtime.raidDungeon, runtime.lifecycleToken)
}
// completeOwner publishes the run's own completion event, the way its owner does.
func (f runtimeRaidPartyClearTestFixture) completeOwner(t *testing.T, characterID uint16) {
t.Helper()
_, runtime := f.sessionFor(t, characterID)
if err := f.service.applyRuntimeRaidDungeonLifecycleEvent(
runtimeRaidDungeonEventForTest(runtime.raidDungeon, characterID, runtimeRaidDungeonCompleted),
); err != nil {
t.Fatalf("char %d completed: %v", characterID, err)
}
}
func (f runtimeRaidPartyClearTestFixture) sessionFor(
t *testing.T,
characterID uint16,
) (*gameSession, *runtimeDungeonState) {
t.Helper()
runtime := f.runs[characterID]
if runtime == nil {
t.Fatalf("char %d never entered the gate", characterID)
}
session := &gameSession{
selectedCharacterID: characterID,
dungeon: dungeonSessionState{runtime: runtime},
}
return session, runtime
}
func (f runtimeRaidPartyClearTestFixture) objective(t *testing.T, mapID int) runtimeRaidDungeonObjective {
t.Helper()
f.service.raidMu.Lock()
defer f.service.raidMu.Unlock()
objective, ok := f.service.raids[f.raidKey].Lifecycle.Objectives[mapID]
if !ok {
t.Fatalf("objective %d missing", mapID)
}
return objective
}
func (f runtimeRaidPartyClearTestFixture) clearCounts(t *testing.T) map[uint16]uint32 {
t.Helper()
f.service.raidMu.Lock()
defer f.service.raidMu.Unlock()
counts := make(map[uint16]uint32, len(f.service.raids[f.raidKey].Lifecycle.PhaseClearCounts))
for characterID, count := range f.service.raids[f.raidKey].Lifecycle.PhaseClearCounts {
counts[characterID] = count
}
return counts
}
func (f runtimeRaidPartyClearTestFixture) openRunCount(t *testing.T) int {
t.Helper()
f.service.raidMu.Lock()
defer f.service.raidMu.Unlock()
return len(f.service.raids[f.raidKey].Lifecycle.Dungeons)
}