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