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

309 lines
10 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 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")
}
})
}