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