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

210 lines
8.1 KiB
Go

package dnfbridge
import (
"testing"
"longheng.io/server/internal/modules/dnf/actionobject"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
"longheng.io/server/internal/modules/dnf/worldmap"
)
// The three 能源储藏室 scripts, verbatim in shape (see actionobject tests for
// the transcription). The runtime is exercised through them because their
// trigger-switch choreography is exactly what a per-visit model gets wrong.
const (
testEnergySaveGateScript = "" +
"[TRIGGER]\n[LIMIT]\n1\n[ENABLE]\n[ON]\n" +
"[DUNGEON SYMBOL COMPARE]\n`storage gate open`\n[<]\n2\n[DO BEHAVIOR]\n[ME]\n0\n[/TRIGGER]\n" +
"[BEHAVIOR]\n[NOTICE]\n`gate closed` 255 255 0 `None` 4000 1000\n[/NOTICE]\n" +
"[GATE ENABLE]\n-1 -1 0\n[DOWN]\n[/GATE ENABLE]\n[TRIGGER]\n1\n[ON]\n[TRIGGER]\n0\n[OFF]\n[/BEHAVIOR]\n" +
"[TRIGGER]\n[LIMIT]\n1\n[ENABLE]\n[OFF]\n" +
"[DUNGEON SYMBOL COMPARE]\n`storage gate open`\n[>=]\n2\n[DO BEHAVIOR]\n[ME]\n1\n[/TRIGGER]\n" +
"[BEHAVIOR]\n[NOTICE]\n`gate open` 255 255 0 `None` 4000 1000\n[/NOTICE]\n" +
"[GATE ENABLE]\n-1 -1 1\n[DOWN]\n[/GATE ENABLE]\n[/BEHAVIOR]\n"
testEnergySaveCylinderScript = "" +
"[TRIGGER]\n[LIMIT]\n1\n[WHICH]\n[MONSTER]\n[CHECKUP]\n[IS INDEX]\n%d\n[/IS INDEX]\n" +
"[CHECKED NO]\n[<]\n1\n[/CHECKUP]\n[DO BEHAVIOR]\n[ME]\n0\n[/TRIGGER]\n" +
"[BEHAVIOR]\n[DUNGEON SYMBOL CALC]\n`storage gate open`\n[+]\n1\n[/BEHAVIOR]\n"
)
func testActionObjectScript(t *testing.T, name, text string) actionobject.Script {
t.Helper()
document, err := dnfpvf.Parse(name, text)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
script, err := actionobject.Parse(document)
if err != nil {
t.Fatalf("decode %s: %v", name, err)
}
return script
}
func testActionObjectRoom(x, y int64, mapID int64, monsterID int64) *runtimeDungeonRoom {
room := &runtimeDungeonRoom{
coordinate: worldmap.RoomCoordinate{X: x, Y: y},
mapID: mapID,
byObjectKey: make(map[uint32]int),
}
if monsterID != 0 {
room.monsters = append(room.monsters, runtimeDungeonMonster{
ObjectKey: 1,
Spawn: worldmap.MonsterSpawn{MonsterID: monsterID},
State: runtimeDungeonMonsterAnnounced,
})
room.byObjectKey[1] = 0
}
return room
}
func testActionObjectInstance(objectID int64, key runtimeDungeonRoomKey, script actionobject.Script) *runtimeDungeonActionObjectInstance {
instance := &runtimeDungeonActionObjectInstance{
ObjectID: objectID,
Room: key,
Script: script,
Triggers: make([]runtimeDungeonActionObjectTrigger, len(script.Triggers)),
}
for index, trigger := range script.Triggers {
instance.Triggers[index].Enabled = trigger.Enabled
}
return instance
}
// TestActionObjectsOpenEnergySaveBossDoorAfterBothCylinders walks the room
// order a squad actually takes: start room (door closes), left room kill,
// back through start, right room kill, back to start (door opens).
func TestActionObjectsOpenEnergySaveBossDoorAfterBothCylinders(t *testing.T) {
s := &Service{}
start := runtimeDungeonRoomKey{X: 1, Y: 0, MapID: 74249}
left := runtimeDungeonRoomKey{X: 0, Y: 0, MapID: 74248}
right := runtimeDungeonRoomKey{X: 2, Y: 0, MapID: 74250}
startRoom := testActionObjectRoom(1, 0, 74249, 0)
leftRoom := testActionObjectRoom(0, 0, 74248, 57156)
rightRoom := testActionObjectRoom(2, 0, 74250, 56068)
runtime := &runtimeDungeonState{
Room: startRoom,
Rooms: map[runtimeDungeonRoomKey]*runtimeDungeonRoomVisit{
start: {Room: startRoom},
left: {Room: leftRoom},
right: {Room: rightRoom},
},
}
state := runtime.actionObjectState()
state.Instances = []*runtimeDungeonActionObjectInstance{
testActionObjectInstance(64308, start, testActionObjectScript(t, "gate", testEnergySaveGateScript)),
testActionObjectInstance(64307, left, testActionObjectScript(t, "left", sprintfScript(testEnergySaveCylinderScript, 57156))),
testActionObjectInstance(64306, right, testActionObjectScript(t, "right", sprintfScript(testEnergySaveCylinderScript, 56068))),
}
// Entering the start room: symbol 0 < 2, the door closes and the reopen
// trigger is armed.
effects := s.evaluateActionObjectsLocked(runtime, start)
if effects.GateOpen == nil || *effects.GateOpen {
t.Fatalf("start room gate after first entry = %v want closed", effects.GateOpen)
}
if len(effects.Notices) != 1 || effects.Notices[0].Text != "gate closed" {
t.Fatalf("first-entry notices = %+v", effects.Notices)
}
if !state.Instances[0].Triggers[1].Enabled || state.Instances[0].Triggers[0].Enabled {
t.Fatalf("gate triggers after close = %+v", state.Instances[0].Triggers)
}
// Left room: kill 57156. The cylinder counts it and the symbol reaches 1.
runtime.Room = leftRoom
leftRoom.monsters[0].State = runtimeDungeonMonsterDefeated
effects = s.evaluateActionObjectsLocked(runtime, left)
if state.Symbols["storage gate open"] != 1 {
t.Fatalf("symbol after left kill = %d want 1", state.Symbols["storage gate open"])
}
if effects.GateOpen != nil {
t.Fatalf("left room reported a gate verdict %v; it has no gate script", *effects.GateOpen)
}
// Back through the start room: still short, nothing new fires, door stays shut.
runtime.Room = startRoom
effects = s.evaluateActionObjectsLocked(runtime, start)
if effects.Fired != 0 || effects.GateOpen == nil || *effects.GateOpen {
t.Fatalf("start room mid-way: fired=%d gate=%v", effects.Fired, effects.GateOpen)
}
// Right room: kill 56068 -> symbol 2. The gate object is not in this room,
// but it is evaluated anyway and flips its own room's gate open now.
runtime.Room = rightRoom
rightRoom.monsters[0].State = runtimeDungeonMonsterDefeated
effects = s.evaluateActionObjectsLocked(runtime, right)
if state.Symbols["storage gate open"] != 2 {
t.Fatalf("symbol after right kill = %d want 2", state.Symbols["storage gate open"])
}
if len(effects.Notices) != 0 {
t.Fatalf("the opening notice leaked into the right room: %+v", effects.Notices)
}
if !state.gateOpen[start] {
t.Fatal("start room gate did not open once the symbol reached 2")
}
// Walking back into the start room publishes the open verdict.
runtime.Room = startRoom
effects = s.evaluateActionObjectsLocked(runtime, start)
if effects.GateOpen == nil || !*effects.GateOpen {
t.Fatalf("start room final gate = %v want open", effects.GateOpen)
}
// [LIMIT] 1 on both triggers: nothing fires again.
if again := s.evaluateActionObjectsLocked(runtime, start); again.Fired != 0 {
t.Fatalf("triggers fired past their limit: %+v", again)
}
}
// TestActionObjectsReopenSurvivesLeavingBeforeSymbolIsMet is the ordering a
// per-visit object model breaks: the squad leaves the start room with the
// reopen trigger armed, the symbol is completed elsewhere, and the door must
// still open on return even though the closing trigger no longer matches.
func TestActionObjectsReopenSurvivesLeavingBeforeSymbolIsMet(t *testing.T) {
s := &Service{}
start := runtimeDungeonRoomKey{X: 1, Y: 0, MapID: 74249}
startRoom := testActionObjectRoom(1, 0, 74249, 0)
runtime := &runtimeDungeonState{
Room: startRoom,
Rooms: map[runtimeDungeonRoomKey]*runtimeDungeonRoomVisit{start: {Room: startRoom}},
}
state := runtime.actionObjectState()
state.Instances = []*runtimeDungeonActionObjectInstance{
testActionObjectInstance(64308, start, testActionObjectScript(t, "gate", testEnergySaveGateScript)),
}
s.evaluateActionObjectsLocked(runtime, start)
// Both cylinders count in while the squad is elsewhere.
state.Symbols["storage gate open"] = 2
effects := s.evaluateActionObjectsLocked(runtime, start)
if effects.GateOpen == nil || !*effects.GateOpen {
t.Fatalf("gate after symbol met = %v want open", effects.GateOpen)
}
if len(effects.Notices) != 1 || effects.Notices[0].Text != "gate open" {
t.Fatalf("reopen notices = %+v", effects.Notices)
}
}
func sprintfScript(format string, monsterID int64) string {
out := make([]byte, 0, len(format)+8)
for i := 0; i < len(format); i++ {
if format[i] == '%' && i+1 < len(format) && format[i+1] == 'd' {
out = append(out, []byte(itoaTest(monsterID))...)
i++
continue
}
out = append(out, format[i])
}
return string(out)
}
func itoaTest(value int64) string {
if value == 0 {
return "0"
}
var digits []byte
for value > 0 {
digits = append([]byte{byte('0' + value%10)}, digits...)
value /= 10
}
return string(digits)
}