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,308 @@
package raid
import (
"os"
"testing"
"time"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestFiendWarIndependentMovementAndRecovery(t *testing.T) {
now := time.Unix(1700000000, 0)
b := &FiendWarBattle{
Config: &FiendWarBattleConfig{WaitingArea: 2, DieImprisonSeconds: []int{20, 30, 50, 80, 120}, Areas: map[int]FiendWarArea{
2: {Index: 2, Next: []int{3, 5}}, 3: {Index: 3, Next: []int{2}}, 5: {Index: 5, Next: []int{2, 8}}, 8: {Index: 8, Next: []int{5}},
}},
Areas: map[int]FiendWarAreaState{}, GroupAreas: map[byte]int{1: 5}, MemberAreas: map[uint16]int{1: 5, 2: 2},
}
if err := b.MoveMember(2, 1, 3, now); err != nil {
t.Fatal("member used leader's area instead of own", err)
}
if b.MemberArea(1, 1) != 5 || b.MemberArea(2, 1) != 3 {
t.Fatal("one movement teleported both members")
}
if err := b.MoveMember(2, 1, 8, now); err == nil {
t.Fatal("accepted a nonadjacent member move")
}
for _, seconds := range []int{20, 30, 50, 80, 120, 120} {
b.ImprisonMember(2, now)
until := now.Add(time.Duration(seconds) * time.Second)
if b.MemberArea(2, 1) != 2 || b.RecoveryUntil[2] != until {
t.Fatal("wrong imprisonment tier", b.RecoveryUntil[2])
}
if err := b.MoveMember(2, 1, 3, until.Add(-time.Nanosecond)); err == nil {
t.Fatal("escaped imprisonment early")
}
if err := b.MoveMember(2, 1, 3, until); err != nil {
t.Fatal(err)
}
now = until
}
clone := b.Clone()
clone.ImprisonMember(2, now)
if b.ImprisonCounts[2] != 6 || b.MemberArea(2, 1) != 3 {
t.Fatal("snapshot changed live recovery")
}
}
func TestFiendWarAreaMemberLimit(t *testing.T) {
b := &FiendWarBattle{
Config: &FiendWarBattleConfig{WaitingArea: 2, Areas: map[int]FiendWarArea{
2: {Index: 2, Next: []int{3}, MemberMax: 8},
3: {Index: 3, Next: []int{2}, MemberMax: 4},
}},
Areas: map[int]FiendWarAreaState{}, GroupAreas: map[byte]int{1: 2},
MemberAreas: map[uint16]int{1: 3, 2: 3, 3: 3, 4: 3, 5: 2},
}
if err := b.MoveMember(5, 1, 3, time.Now()); err == nil || b.MemberAreas[5] != 2 {
t.Fatal("fifth member entered a four-member area")
}
if err := b.MoveMember(1, 1, 3, time.Now()); err != nil {
t.Fatal("existing member counted twice", err)
}
if err := b.MoveMember(1, 1, 2, time.Now()); err != nil {
t.Fatal(err)
}
if err := b.MoveMember(5, 1, 3, time.Now()); err != nil {
t.Fatal("released area slot stayed occupied", err)
}
}
// 站在没打完的区域里也必须能继续走。
//
// 原来 Move 里有一条"fiendwar source area requires combat":从还没清掉敌人的区域走出去
// 一律拒绝。客户端自己不认这条规则(它照走不误),于是 2026-10-01 真机出现
// "客户端的角色已经在 B、服务端 MemberAreas 还记着 A" 的分叉,直接导致
// 「同区域不一起进图」,以及之后他再按进图被同一条拒绝挡下时的**卡死**。
// 地图推进由 [AREA INFO] 的相邻关系与 BOSS 门禁把关,不需要这条自造规则。
func TestFiendWarMoveAllowsLeavingAnUnclearedArea(t *testing.T) {
b := &FiendWarBattle{
Config: &FiendWarBattleConfig{WaitingArea: 2, Areas: map[int]FiendWarArea{
2: {Index: 2, Next: []int{6}, MemberMax: 8},
6: {Index: 6, Dungeon: 4954, Next: []int{2, 10}, MemberMax: 4},
10: {Index: 10, Dungeon: 4958, Next: []int{6}, MemberMax: 4},
}},
// 区域 6 里还站着一只没打掉的守军 —— 这正是被拒的那种输入。
Areas: map[int]FiendWarAreaState{6: {Enemy: FiendWarUnit{ID: 11, Type: 1}}},
GroupAreas: map[byte]int{1: 6},
MemberAreas: map[uint16]int{1: 6},
}
if !b.AreaOpen(6) {
t.Fatal("fixture 没造出'区域里还有敌人'这个输入")
}
if err := b.MoveMember(1, 1, 10, time.Now()); err != nil {
t.Fatalf("站在没打完的区域里走不出去:%v", err)
}
if b.MemberArea(1, 1) != 10 {
t.Fatalf("走位没有被记进战场:area=%d want 10", b.MemberArea(1, 1))
}
}
func TestFiendWarMonstersSpawnAwayFromEnemyArmies(t *testing.T) {
b := &FiendWarBattle{
Config: &FiendWarBattleConfig{Areas: map[int]FiendWarArea{
14: {Index: 14, Dungeon: 4962}, 16: {Index: 16, Dungeon: 4964}, 18: {Index: 18, Dungeon: 4966},
3: {Index: 3, Dungeon: 4951}, 5: {Index: 5, Dungeon: 4953},
}},
Areas: map[int]FiendWarAreaState{
14: {Enemy: FiendWarUnit{ID: 1, Type: 1}}, 16: {Enemy: FiendWarUnit{ID: 2, Type: 2}},
18: {Enemy: FiendWarUnit{ID: 3, Type: 3}}, 3: {}, 5: {},
},
Symbols: map[uint32]int{}, NextUnitID: 3,
}
b.act(FiendWarInstruction{Op: "create monsters"}, time.Unix(1700000000, 0))
for _, id := range []int{14, 16, 18} {
if b.Areas[id].Monster.ID != 0 {
t.Fatalf("spawn created an immediate enemy/monster battle in area %d", id)
}
}
for _, id := range []int{3, 5} {
if b.Areas[id].Monster.ID == 0 {
t.Fatalf("empty area %d was not populated", id)
}
}
}
func TestRealScriptPVFFiendWarBattle(t *testing.T) {
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
if path == "" {
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatal(err)
}
c, err := LoadFiendWarConfig(archive)
if err != nil {
t.Fatal(err)
}
b, err := NewFiendWarBattle(c.Battle)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC)
if err = b.Apply(FiendWarEvent{}, now); err != nil {
t.Fatal(err)
}
if len(c.Battle.Areas) != 30 || len(c.Battle.Raid.Phases) != 1 || b.Value("fiendwar init") != 1 {
t.Fatalf("initial battle: %+v", b)
}
for _, id := range []int{14, 16, 18} {
if b.Areas[id].Enemy.ID == 0 {
t.Fatalf("missing initial enemy %d", id)
}
}
if err = b.Tick(now.Add(time.Second)); err != nil {
t.Fatal(err)
}
if b.Value("alive monster count") != 3 {
t.Fatal("monster population", b.Value("alive monster count"))
}
if err = b.Move(1, 30); err == nil {
t.Fatal("accepted boss teleport")
}
// Isolate the real clearing/restore triggers from unrelated moving armies.
for _, id := range []int{27, 28, 29} {
a := c.Battle.Areas[id]
b.GroupAreas[1] = id
if err = b.Enter(1, a.Dungeon, now); err != nil {
t.Fatal(err)
}
if err = b.Clear(a.Dungeon, now); err != nil {
t.Fatal(err)
}
}
if !b.AreaOpen(30) || !b.AreaOpen(31) {
t.Fatal("three center clears did not open both bosses")
}
if b.Timers[FiendWarTimerKey{27, "top_restore_center"}].Deadline != now.Add(720*time.Second) {
t.Fatal("PVF center restore timer missing")
}
for i, id := range []int{30, 31} {
a := c.Battle.Areas[id]
b.GroupAreas[byte(i+1)] = id
if err = b.Enter(byte(i+1), a.Dungeon, now); err != nil {
t.Fatal(err)
}
}
if err = b.Clear(4978, now); err == nil {
t.Fatal("accepted boss clear before mechanics")
}
for _, id := range []int{306000463, 306000460} {
if err = b.Apply(FiendWarEvent{Kind: "on enter map", ID: id}, now); err != nil {
t.Fatal(err)
}
}
if b.Value("boss pattern state") != 1111 {
t.Fatal("boss mechanisms not initialized")
}
// 四颗球是**两队共享的池子**:每一侧各拿一颗不同的球,谁打掉自己那颗,
// 那颗球就从机制条上永久消失、该侧再从剩下的球里补一颗;
// 四颗都清掉 → 两侧都到 9(本体破防)。
for round := 0; round < 2; round++ {
for _, room := range []struct {
dungeon int
side string
}{{4978, "top boss"}, {4953, "bot boss"}} {
state := b.Value(room.side + " state")
if state < 5 || state > 8 {
t.Fatalf("round %d: %s has no ball to kill (state=%d)", round, room.side, state)
}
otherSide := "bot boss"
if room.side == "bot boss" {
otherSide = "top boss"
}
otherBefore := b.Value(otherSide + " state")
patternBefore := b.Value("boss pattern state")
if err = b.BossReport(room.dungeon, 1, state, now); err != nil {
t.Fatal(err)
}
if got := b.Value("boss pattern state"); got != patternBefore-fiendWarCloneWeights[state-5] {
t.Fatalf("%s kind=1 value=%d cleared the wrong ball: pattern %d → %d",
room.side, state, patternBefore, got)
}
if got := b.Value(otherSide + " state"); got != otherBefore {
t.Fatalf("%s 打掉自己那颗球,却动了对面:%s state %d → %d",
room.side, otherSide, otherBefore, got)
}
}
}
if b.Value("boss pattern state") != 0 {
t.Fatalf("四颗球清完后机制条 = %d,want 0", b.Value("boss pattern state"))
}
if b.Value("top boss state") != 9 || b.Value("bot boss state") != 9 {
t.Fatalf("四颗球清完后两侧应当都是 9(本体破防),got top=%d bot=%d",
b.Value("top boss state"), b.Value("bot boss state"))
}
for _, id := range []int{4978, 4953} {
if err = b.BossReport(id, 0, 0, now); err != nil {
t.Fatal(err)
}
}
if err = b.Clear(4978, now); err != nil {
t.Fatal(err)
}
if b.Complete {
t.Fatal("one boss granted victory")
}
if err = b.Clear(4953, now); err != nil {
t.Fatal(err)
}
if !b.Complete {
t.Fatal("both bosses did not finish raid")
}
clone := b.Clone()
clone.Symbols[92] = 0
if b.Symbols[92] != 10 {
t.Fatal("snapshot aliases live state")
}
failed, _ := NewFiendWarBattle(c.Battle)
if err = failed.Apply(FiendWarEvent{}, now); err != nil {
t.Fatal(err)
}
for i := 1; i <= 30 && !failed.Failed; i++ {
if err = failed.Tick(now.Add(time.Duration(i) * 150 * time.Second)); err != nil {
t.Fatal(err)
}
}
if !failed.Failed {
t.Fatal("enemy arrival did not fail defense")
}
t.Run("boss retreat and retry", func(t *testing.T) {
b, _ := NewFiendWarBattle(c.Battle)
if err := b.Apply(FiendWarEvent{}, now); err != nil {
t.Fatal(err)
}
for _, name := range []string{"top command center state", "mid command center state", "bot command center state"} {
b.set(name, 0)
}
if err := b.Apply(FiendWarEvent{}, now); err != nil {
t.Fatal(err)
}
b.GroupAreas[1], b.GroupAreas[2] = 30, 31
for i, dungeon := range []int{4978, 4953} {
if err := b.Enter(byte(i+1), dungeon, now); err != nil {
t.Fatal(err)
}
}
if err := b.Tick(now.Add(600 * time.Second)); err != nil {
t.Fatal(err)
}
if b.Areas[30].Group != 0 || b.Areas[31].Group != 0 || len(b.Retreats) != 2 {
t.Fatal("timeout did not release both boss rooms")
}
if b.Value("top boss state") != 1 || b.Value("bot boss state") != 1 {
t.Fatal("boss rooms did not reopen")
}
if b.GroupAreas[1] != 30 || b.GroupAreas[2] != 31 {
t.Fatal("retreat diverged from frozen town origins")
}
if err := b.Enter(1, 4978, now.Add(601*time.Second)); err != nil {
t.Fatal(err)
}
if b.Value("kick out character") != 0 {
t.Fatal("retry retained old kick request")
}
})
}