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,90 @@
|
||||
package actionobject
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// TestRealScriptPVFEnergySaveGateScripts decodes the shipped 能源储藏室 scripts
|
||||
// rather than the hand-written fixtures above, so a grammar the fixtures do not
|
||||
// reproduce cannot pass unnoticed.
|
||||
func TestRealScriptPVFEnergySaveGateScripts(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to decode the shipped action-object scripts")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const base = "passiveobject/actionobject/map/luke_raid/phase2/enegy_save/action/"
|
||||
read := func(name string) Script {
|
||||
t.Helper()
|
||||
text, readErr := archive.ReadText(base + name)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read %s: %v", name, readErr)
|
||||
}
|
||||
document, parseErr := dnfpvf.Parse(base+name, text)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("parse %s: %v", name, parseErr)
|
||||
}
|
||||
script, decodeErr := Parse(document)
|
||||
if decodeErr != nil {
|
||||
t.Fatalf("decode %s: %v", name, decodeErr)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
gate := read("gate_control_gate.act")
|
||||
if len(gate.Triggers) != 2 || len(gate.Behaviors) != 2 {
|
||||
t.Fatalf("gate script=%+v", gate)
|
||||
}
|
||||
for index, want := range []Condition{
|
||||
{Kind: ConditionDungeonSymbol, Symbol: "storage gate open", Op: CompareLess, Value: 2},
|
||||
{Kind: ConditionDungeonSymbol, Symbol: "storage gate open", Op: CompareGreaterEqual, Value: 2},
|
||||
} {
|
||||
conditions := gate.Triggers[index].Conditions
|
||||
if len(conditions) != 1 || conditions[0] != want {
|
||||
t.Fatalf("gate trigger %d conditions=%+v want %+v (unsupported=%v)",
|
||||
index, conditions, want, gate.Triggers[index].Unsupported)
|
||||
}
|
||||
}
|
||||
if gate.Triggers[0].Enabled == gate.Triggers[1].Enabled {
|
||||
t.Fatalf("gate triggers must ship in opposite states: %+v", gate.Triggers)
|
||||
}
|
||||
opened := false
|
||||
for _, operation := range gate.Behaviors[1].Operations {
|
||||
if operation.Kind == OperationGateEnable && operation.GateEnabled &&
|
||||
len(operation.GateDirections) == 1 && operation.GateDirections[0] == "down" {
|
||||
opened = true
|
||||
}
|
||||
}
|
||||
if !opened {
|
||||
t.Fatalf("gate behavior 1 never opens the down gate: %+v", gate.Behaviors[1])
|
||||
}
|
||||
|
||||
for name, monsterID := range map[string]int64{
|
||||
"gate_control_left.act": 57156,
|
||||
"gate_control_right.act": 56068,
|
||||
} {
|
||||
cylinder := read(name)
|
||||
if len(cylinder.Triggers) != 1 || len(cylinder.Behaviors) != 1 {
|
||||
t.Fatalf("%s=%+v", name, cylinder)
|
||||
}
|
||||
conditions := cylinder.Triggers[0].Conditions
|
||||
want := Condition{Kind: ConditionMonsterCount, MonsterID: monsterID, Op: CompareLess, Value: 1}
|
||||
if len(conditions) != 1 || conditions[0] != want {
|
||||
t.Fatalf("%s conditions=%+v want %+v (unsupported=%v)",
|
||||
name, conditions, want, cylinder.Triggers[0].Unsupported)
|
||||
}
|
||||
operations := cylinder.Behaviors[0].Operations
|
||||
if len(operations) != 1 || operations[0].Kind != OperationSymbolCalc ||
|
||||
operations[0].Symbol != "storage gate open" ||
|
||||
operations[0].Calc != "+" || operations[0].Value != 1 {
|
||||
t.Fatalf("%s operations=%+v", name, operations)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
// Package actionobject parses the PVF action-object scripts that a dungeon's
|
||||
// passive objects run.
|
||||
//
|
||||
// A placed `[passive object]` resolves through passiveobject/passiveobject.lst
|
||||
// to an `.obj` definition whose `[basic action]` names an `.act` script. The
|
||||
// script is a flat, ordered sequence of `[TRIGGER]` / `[BEHAVIOR]` blocks: a
|
||||
// trigger tests dungeon symbols and room actors and names the behavior it runs,
|
||||
// a behavior mutates dungeon symbols, opens or closes room gates and posts
|
||||
// messages.
|
||||
//
|
||||
// Only the operations the server can actually honour are decoded. Everything
|
||||
// else is recorded by name on the owning block so a caller can log what it
|
||||
// skipped rather than silently mis-running a script.
|
||||
package actionobject
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
var ErrDocumentRequired = errors.New("dnf action object document is required")
|
||||
|
||||
// CompareOp is the comparison a `[CHECKUP]` or `[DUNGEON SYMBOL COMPARE]`
|
||||
// applies. The script spells it as its own section, e.g. `[<]` then the operand.
|
||||
type CompareOp string
|
||||
|
||||
const (
|
||||
CompareLess CompareOp = "<"
|
||||
CompareLessEqual CompareOp = "<="
|
||||
CompareEqual CompareOp = "="
|
||||
CompareNotEqual CompareOp = "!="
|
||||
CompareGreaterEqual CompareOp = ">="
|
||||
CompareGreater CompareOp = ">"
|
||||
)
|
||||
|
||||
// Satisfied reports whether left <op> right holds.
|
||||
func (op CompareOp) Satisfied(left, right int64) bool {
|
||||
switch op {
|
||||
case CompareLess:
|
||||
return left < right
|
||||
case CompareLessEqual:
|
||||
return left <= right
|
||||
case CompareEqual:
|
||||
return left == right
|
||||
case CompareNotEqual:
|
||||
return left != right
|
||||
case CompareGreaterEqual:
|
||||
return left >= right
|
||||
case CompareGreater:
|
||||
return left > right
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ConditionKind string
|
||||
|
||||
const (
|
||||
// ConditionDungeonSymbol is `[DUNGEON SYMBOL COMPARE] name` + `[<] n`.
|
||||
ConditionDungeonSymbol ConditionKind = "dungeon_symbol"
|
||||
// ConditionMonsterCount is `[WHICH][MONSTER][CHECKUP][IS INDEX] id
|
||||
// [/IS INDEX][CHECKED NO][<] n [/CHECKUP]`: how many live monsters of that
|
||||
// index the room still holds.
|
||||
ConditionMonsterCount ConditionKind = "monster_count"
|
||||
)
|
||||
|
||||
type Condition struct {
|
||||
Kind ConditionKind
|
||||
Symbol string
|
||||
MonsterID int64
|
||||
Op CompareOp
|
||||
Value int64
|
||||
}
|
||||
|
||||
type OperationKind string
|
||||
|
||||
const (
|
||||
OperationSymbolCalc OperationKind = "dungeon_symbol_calc"
|
||||
OperationSymbolSet OperationKind = "dungeon_symbol_set"
|
||||
OperationGateEnable OperationKind = "gate_enable"
|
||||
OperationNotice OperationKind = "notice"
|
||||
OperationCenterMessage OperationKind = "center_msg"
|
||||
OperationTriggerSwitch OperationKind = "trigger_switch"
|
||||
OperationDestroy OperationKind = "destroy"
|
||||
)
|
||||
|
||||
type Operation struct {
|
||||
Kind OperationKind
|
||||
Symbol string
|
||||
// Calc is `+` or `-` for OperationSymbolCalc.
|
||||
Calc string
|
||||
Value int64
|
||||
// GateEnabled and GateDirections carry `[GATE ENABLE] x y enable` plus the
|
||||
// `[DOWN]` / `[LEFT]` / `[RIGHT]` / `[UP]` sections inside its block.
|
||||
GateEnabled bool
|
||||
GateDirections []string
|
||||
Text string
|
||||
// Color is the ARGB the script's `r g b` triple builds, opaque.
|
||||
Color uint32
|
||||
DurationMS int64
|
||||
TriggerIndex int
|
||||
TriggerOn bool
|
||||
}
|
||||
|
||||
type Trigger struct {
|
||||
Index int
|
||||
// Limit is how many times the trigger may fire; 0 means unlimited.
|
||||
Limit int
|
||||
// Enabled is the trigger's state before any behavior switches it. A script
|
||||
// writes `[ENABLE][OFF]` for a trigger another behavior turns on later.
|
||||
Enabled bool
|
||||
Conditions []Condition
|
||||
Behavior int
|
||||
Unsupported []string
|
||||
}
|
||||
|
||||
type Behavior struct {
|
||||
Index int
|
||||
Operations []Operation
|
||||
Unsupported []string
|
||||
}
|
||||
|
||||
type Script struct {
|
||||
Path string
|
||||
Triggers []Trigger
|
||||
Behaviors []Behavior
|
||||
}
|
||||
|
||||
// Drives reports whether the script touches anything the dungeon runtime owns.
|
||||
// A script that only plays animations is not worth instantiating.
|
||||
func (s Script) Drives() bool {
|
||||
for _, behavior := range s.Behaviors {
|
||||
for _, operation := range behavior.Operations {
|
||||
switch operation.Kind {
|
||||
case OperationSymbolCalc, OperationSymbolSet, OperationGateEnable,
|
||||
OperationNotice, OperationCenterMessage:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func compareOpFor(name string) (CompareOp, bool) {
|
||||
switch CompareOp(name) {
|
||||
case CompareLess, CompareLessEqual, CompareEqual, CompareNotEqual, CompareGreaterEqual, CompareGreater:
|
||||
return CompareOp(name), true
|
||||
case "==":
|
||||
return CompareEqual, true
|
||||
case "<>":
|
||||
return CompareNotEqual, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func gateDirectionFor(name string) (string, bool) {
|
||||
switch name {
|
||||
case "up", "down", "left", "right":
|
||||
return name, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// parseState is the cursor the section walk carries. `[TRIGGER]` is overloaded:
|
||||
// at the top level it opens a trigger block, inside a `[BEHAVIOR]` it is the
|
||||
// "switch trigger n on/off" operation, told apart by the integer operand.
|
||||
type parseState struct {
|
||||
script Script
|
||||
trigger *Trigger
|
||||
behavior *Behavior
|
||||
pendingCompare *Condition
|
||||
pendingMonster int64
|
||||
monsterSelected bool
|
||||
checkupOpen bool
|
||||
checkedNo bool
|
||||
enablePending bool
|
||||
gateOpen bool
|
||||
doBehavior bool
|
||||
triggerSwitch bool
|
||||
// pendingCalcIndex is the behavior operation waiting for its `[+] 1`
|
||||
// operator section; -1 when none is open.
|
||||
pendingCalcIndex int
|
||||
}
|
||||
|
||||
// Parse decodes one `.act` document.
|
||||
func Parse(document *dnfpvf.Document) (Script, error) {
|
||||
if document == nil {
|
||||
return Script{}, ErrDocumentRequired
|
||||
}
|
||||
state := parseState{script: Script{Path: document.Path}, pendingCalcIndex: -1}
|
||||
for _, section := range document.Sections {
|
||||
name := strings.ToLower(strings.TrimSpace(section.Name))
|
||||
tokens := sectionTokens(document, section)
|
||||
state.consume(name, tokens)
|
||||
}
|
||||
state.closeTrigger()
|
||||
state.closeBehavior()
|
||||
return state.script, nil
|
||||
}
|
||||
|
||||
func sectionTokens(document *dnfpvf.Document, section dnfpvf.Section) []dnfpvf.Token {
|
||||
if section.Start < 0 || section.End > len(document.Tokens) || section.Start > section.End {
|
||||
return nil
|
||||
}
|
||||
return document.Tokens[section.Start:section.End]
|
||||
}
|
||||
|
||||
func (p *parseState) consume(name string, tokens []dnfpvf.Token) {
|
||||
switch name {
|
||||
case "motion", "/motion", "base ani":
|
||||
return
|
||||
case "trigger":
|
||||
// Inside a behavior this is `[TRIGGER] n` + `[ON]`/`[OFF]`.
|
||||
if p.behavior != nil {
|
||||
p.triggerSwitch = true
|
||||
p.behavior.Operations = append(p.behavior.Operations, Operation{
|
||||
Kind: OperationTriggerSwitch,
|
||||
TriggerIndex: int(firstInt(tokens)),
|
||||
TriggerOn: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
p.closeTrigger()
|
||||
p.trigger = &Trigger{Index: len(p.script.Triggers), Enabled: true, Behavior: -1}
|
||||
case "/trigger":
|
||||
p.closeTrigger()
|
||||
case "behavior":
|
||||
p.closeBehavior()
|
||||
p.behavior = &Behavior{Index: len(p.script.Behaviors)}
|
||||
case "/behavior":
|
||||
p.closeBehavior()
|
||||
case "limit":
|
||||
if p.trigger != nil {
|
||||
p.trigger.Limit = int(firstInt(tokens))
|
||||
}
|
||||
case "enable":
|
||||
p.enablePending = true
|
||||
case "on", "off":
|
||||
p.applySwitch(name == "on")
|
||||
case "which":
|
||||
p.monsterSelected = false
|
||||
case "monster":
|
||||
if p.trigger != nil {
|
||||
p.monsterSelected = true
|
||||
}
|
||||
case "checkup":
|
||||
p.checkupOpen = true
|
||||
p.checkedNo = false
|
||||
p.pendingMonster = 0
|
||||
case "/checkup":
|
||||
p.checkupOpen = false
|
||||
p.checkedNo = false
|
||||
p.monsterSelected = false
|
||||
p.pendingMonster = 0
|
||||
case "is index":
|
||||
p.pendingMonster = firstInt(tokens)
|
||||
case "/is index":
|
||||
case "checked no":
|
||||
p.checkedNo = true
|
||||
case "dungeon symbol compare":
|
||||
p.pendingCompare = &Condition{Kind: ConditionDungeonSymbol, Symbol: firstText(tokens)}
|
||||
case "dungeon symbol calc":
|
||||
p.appendSymbolCalc(tokens)
|
||||
case "dungeon symbol set":
|
||||
if p.behavior != nil {
|
||||
p.behavior.Operations = append(p.behavior.Operations, Operation{
|
||||
Kind: OperationSymbolSet,
|
||||
Symbol: firstText(tokens),
|
||||
Value: firstInt(tokens),
|
||||
})
|
||||
}
|
||||
case "gate enable":
|
||||
p.openGate(tokens)
|
||||
case "/gate enable":
|
||||
p.gateOpen = false
|
||||
case "notice":
|
||||
p.appendMessage(OperationNotice, tokens)
|
||||
case "/notice":
|
||||
case "center msg":
|
||||
p.appendMessage(OperationCenterMessage, tokens)
|
||||
case "destroy":
|
||||
if p.behavior != nil {
|
||||
p.behavior.Operations = append(p.behavior.Operations, Operation{Kind: OperationDestroy})
|
||||
}
|
||||
case "do behavior":
|
||||
p.doBehavior = true
|
||||
case "me":
|
||||
p.applyMe(tokens)
|
||||
case "frame":
|
||||
// A frame gate is animation timing, not a gameplay condition: the
|
||||
// server has no animation clock, so the trigger is treated as always
|
||||
// reachable and its other conditions decide.
|
||||
default:
|
||||
p.consumeUnknown(name, tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parseState) consumeUnknown(name string, tokens []dnfpvf.Token) {
|
||||
// `[DUNGEON SYMBOL CALC] name` is finished by its operator section, e.g.
|
||||
// `[+]` then 1. `[=]` is also a comparison, so the open calc wins while one
|
||||
// is pending and the trigger's comparison table is consulted otherwise.
|
||||
if p.pendingCalcIndex >= 0 && p.behavior != nil &&
|
||||
(name == "+" || name == "-" || name == "=") {
|
||||
operation := &p.behavior.Operations[p.pendingCalcIndex]
|
||||
if name == "=" {
|
||||
operation.Kind = OperationSymbolSet
|
||||
} else {
|
||||
operation.Calc = name
|
||||
}
|
||||
operation.Value = firstInt(tokens)
|
||||
p.pendingCalcIndex = -1
|
||||
return
|
||||
}
|
||||
if op, ok := compareOpFor(name); ok {
|
||||
p.applyCompare(op, firstInt(tokens))
|
||||
return
|
||||
}
|
||||
if direction, ok := gateDirectionFor(name); ok && p.gateOpen && p.behavior != nil {
|
||||
last := len(p.behavior.Operations) - 1
|
||||
if last >= 0 && p.behavior.Operations[last].Kind == OperationGateEnable {
|
||||
p.behavior.Operations[last].GateDirections = append(
|
||||
p.behavior.Operations[last].GateDirections, direction)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case p.behavior != nil:
|
||||
p.behavior.Unsupported = appendUnique(p.behavior.Unsupported, name)
|
||||
case p.trigger != nil:
|
||||
p.trigger.Unsupported = appendUnique(p.trigger.Unsupported, name)
|
||||
}
|
||||
}
|
||||
|
||||
// applyCompare closes whichever comparison was left open: a dungeon symbol
|
||||
// compare, or the live-monster count of a `[CHECKUP]`.
|
||||
func (p *parseState) applyCompare(op CompareOp, value int64) {
|
||||
switch {
|
||||
case p.pendingCompare != nil:
|
||||
condition := *p.pendingCompare
|
||||
condition.Op = op
|
||||
condition.Value = value
|
||||
p.pendingCompare = nil
|
||||
if p.trigger != nil {
|
||||
p.trigger.Conditions = append(p.trigger.Conditions, condition)
|
||||
}
|
||||
case p.checkupOpen && p.checkedNo && p.monsterSelected && p.trigger != nil:
|
||||
p.trigger.Conditions = append(p.trigger.Conditions, Condition{
|
||||
Kind: ConditionMonsterCount,
|
||||
MonsterID: p.pendingMonster,
|
||||
Op: op,
|
||||
Value: value,
|
||||
})
|
||||
p.checkedNo = false
|
||||
case p.trigger != nil:
|
||||
p.trigger.Unsupported = appendUnique(p.trigger.Unsupported, string(op))
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parseState) applySwitch(on bool) {
|
||||
switch {
|
||||
case p.triggerSwitch && p.behavior != nil:
|
||||
last := len(p.behavior.Operations) - 1
|
||||
if last >= 0 && p.behavior.Operations[last].Kind == OperationTriggerSwitch {
|
||||
p.behavior.Operations[last].TriggerOn = on
|
||||
}
|
||||
p.triggerSwitch = false
|
||||
case p.enablePending && p.trigger != nil:
|
||||
p.trigger.Enabled = on
|
||||
p.enablePending = false
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parseState) applyMe(tokens []dnfpvf.Token) {
|
||||
if p.doBehavior && p.trigger != nil {
|
||||
p.trigger.Behavior = int(firstInt(tokens))
|
||||
}
|
||||
p.doBehavior = false
|
||||
}
|
||||
|
||||
func (p *parseState) appendSymbolCalc(tokens []dnfpvf.Token) {
|
||||
if p.behavior == nil {
|
||||
return
|
||||
}
|
||||
p.behavior.Operations = append(p.behavior.Operations, Operation{
|
||||
Kind: OperationSymbolCalc,
|
||||
Symbol: firstText(tokens),
|
||||
Calc: "+",
|
||||
})
|
||||
// The operator and operand arrive as their own sections, e.g. `[+]` then 1.
|
||||
p.pendingCalcIndex = len(p.behavior.Operations) - 1
|
||||
p.pendingCompare = nil
|
||||
}
|
||||
|
||||
func (p *parseState) openGate(tokens []dnfpvf.Token) {
|
||||
if p.behavior == nil {
|
||||
return
|
||||
}
|
||||
// `[GATE ENABLE] x y enable`: the coordinate pair is -1 -1 for "this room"
|
||||
// in every shipped script, and only the third field decides open/closed.
|
||||
values := allInts(tokens)
|
||||
enabled := false
|
||||
if len(values) > 0 && values[len(values)-1] != 0 {
|
||||
enabled = true
|
||||
}
|
||||
p.gateOpen = true
|
||||
p.behavior.Operations = append(p.behavior.Operations, Operation{
|
||||
Kind: OperationGateEnable,
|
||||
GateEnabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *parseState) appendMessage(kind OperationKind, tokens []dnfpvf.Token) {
|
||||
if p.behavior == nil {
|
||||
return
|
||||
}
|
||||
operation := Operation{Kind: kind, Text: firstText(tokens), Color: 0xFFFFFFFF}
|
||||
values := allInts(tokens)
|
||||
switch kind {
|
||||
case OperationNotice:
|
||||
// `[NOTICE] text r g b sound fade duration`.
|
||||
if len(values) >= 3 {
|
||||
operation.Color = 0xFF000000 |
|
||||
uint32(clampByte(values[0]))<<16 |
|
||||
uint32(clampByte(values[1]))<<8 |
|
||||
uint32(clampByte(values[2]))
|
||||
}
|
||||
if len(values) >= 5 {
|
||||
operation.DurationMS = values[3]
|
||||
}
|
||||
case OperationCenterMessage:
|
||||
// `[CENTER MSG] text duration`.
|
||||
if len(values) >= 1 {
|
||||
operation.DurationMS = values[0]
|
||||
}
|
||||
}
|
||||
p.behavior.Operations = append(p.behavior.Operations, operation)
|
||||
}
|
||||
|
||||
func (p *parseState) closeTrigger() {
|
||||
if p.trigger == nil {
|
||||
return
|
||||
}
|
||||
p.script.Triggers = append(p.script.Triggers, *p.trigger)
|
||||
p.trigger = nil
|
||||
p.pendingCompare = nil
|
||||
p.monsterSelected = false
|
||||
p.checkupOpen = false
|
||||
p.checkedNo = false
|
||||
p.enablePending = false
|
||||
p.doBehavior = false
|
||||
}
|
||||
|
||||
func (p *parseState) closeBehavior() {
|
||||
if p.behavior == nil {
|
||||
return
|
||||
}
|
||||
p.script.Behaviors = append(p.script.Behaviors, *p.behavior)
|
||||
p.behavior = nil
|
||||
p.gateOpen = false
|
||||
p.triggerSwitch = false
|
||||
p.pendingCalcIndex = -1
|
||||
}
|
||||
|
||||
func firstInt(tokens []dnfpvf.Token) int64 {
|
||||
for _, token := range tokens {
|
||||
if token.Kind == dnfpvf.TokenInt {
|
||||
return token.Int
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func allInts(tokens []dnfpvf.Token) []int64 {
|
||||
values := make([]int64, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if token.Kind == dnfpvf.TokenInt {
|
||||
values = append(values, token.Int)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func firstText(tokens []dnfpvf.Token) string {
|
||||
for _, token := range tokens {
|
||||
if token.Kind == dnfpvf.TokenString {
|
||||
return token.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func clampByte(value int64) int64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 255 {
|
||||
return 255
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func appendUnique(values []string, value string) []string {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package actionobject
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
func parseTestScript(t *testing.T, path, text string) Script {
|
||||
t.Helper()
|
||||
document, err := dnfpvf.Parse(path, text)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
script, err := Parse(document)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// The gate object of 能源储藏室: it closes its own boss door while
|
||||
// `storage gate open` is short of 2 and reopens it once both cylinders have
|
||||
// counted in. Verbatim from
|
||||
// passiveobject/actionobject/map/luke_raid/phase2/enegy_save/action/gate_control_gate.act.
|
||||
const lukeEnergySaveGateScript = "" +
|
||||
"[MOTION]\n[BASE ANI]\n`../`\n[/MOTION]\n" +
|
||||
"[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"
|
||||
|
||||
// The left cylinder: it adds to the symbol once its room's monster is gone.
|
||||
const lukeEnergySaveCylinderScript = "" +
|
||||
"[MOTION]\n[BASE ANI]\n`../`\n[/MOTION]\n" +
|
||||
"[TRIGGER]\n[LIMIT]\n1\n[WHICH]\n[MONSTER]\n[CHECKUP]\n" +
|
||||
"[IS INDEX]\n57156\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 TestParseGateScriptDecodesBothBranches(t *testing.T) {
|
||||
script := parseTestScript(t, "gate_control_gate.act", lukeEnergySaveGateScript)
|
||||
if len(script.Triggers) != 2 || len(script.Behaviors) != 2 {
|
||||
t.Fatalf("triggers=%d behaviors=%d want 2/2 (%+v)", len(script.Triggers), len(script.Behaviors), script)
|
||||
}
|
||||
if !script.Drives() {
|
||||
t.Fatal("gate script does not drive anything")
|
||||
}
|
||||
|
||||
closing := script.Triggers[0]
|
||||
if closing.Limit != 1 || !closing.Enabled || closing.Behavior != 0 {
|
||||
t.Fatalf("closing trigger=%+v", closing)
|
||||
}
|
||||
if len(closing.Conditions) != 1 ||
|
||||
closing.Conditions[0] != (Condition{
|
||||
Kind: ConditionDungeonSymbol, Symbol: "storage gate open", Op: CompareLess, Value: 2,
|
||||
}) {
|
||||
t.Fatalf("closing conditions=%+v", closing.Conditions)
|
||||
}
|
||||
if len(closing.Unsupported) != 0 {
|
||||
t.Fatalf("closing trigger dropped %v", closing.Unsupported)
|
||||
}
|
||||
|
||||
// The reopening trigger ships disabled and is switched on by behavior 0.
|
||||
opening := script.Triggers[1]
|
||||
if opening.Enabled || opening.Behavior != 1 {
|
||||
t.Fatalf("opening trigger=%+v", opening)
|
||||
}
|
||||
if len(opening.Conditions) != 1 || opening.Conditions[0].Op != CompareGreaterEqual {
|
||||
t.Fatalf("opening conditions=%+v", opening.Conditions)
|
||||
}
|
||||
|
||||
closeBehavior := script.Behaviors[0]
|
||||
if len(closeBehavior.Operations) != 4 {
|
||||
t.Fatalf("close behavior operations=%+v", closeBehavior.Operations)
|
||||
}
|
||||
if got := closeBehavior.Operations[0]; got.Kind != OperationNotice ||
|
||||
got.Text != "gate closed" || got.Color != 0xFFFFFF00 || got.DurationMS != 4000 {
|
||||
t.Fatalf("notice=%+v", got)
|
||||
}
|
||||
if got := closeBehavior.Operations[1]; got.Kind != OperationGateEnable || got.GateEnabled ||
|
||||
len(got.GateDirections) != 1 || got.GateDirections[0] != "down" {
|
||||
t.Fatalf("gate close=%+v", got)
|
||||
}
|
||||
if got := closeBehavior.Operations[2]; got.Kind != OperationTriggerSwitch ||
|
||||
got.TriggerIndex != 1 || !got.TriggerOn {
|
||||
t.Fatalf("trigger switch on=%+v", got)
|
||||
}
|
||||
if got := closeBehavior.Operations[3]; got.Kind != OperationTriggerSwitch ||
|
||||
got.TriggerIndex != 0 || got.TriggerOn {
|
||||
t.Fatalf("trigger switch off=%+v", got)
|
||||
}
|
||||
|
||||
openBehavior := script.Behaviors[1]
|
||||
if len(openBehavior.Operations) != 2 {
|
||||
t.Fatalf("open behavior operations=%+v", openBehavior.Operations)
|
||||
}
|
||||
if got := openBehavior.Operations[1]; got.Kind != OperationGateEnable || !got.GateEnabled {
|
||||
t.Fatalf("gate open=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCylinderScriptCountsTheRoomMonster(t *testing.T) {
|
||||
script := parseTestScript(t, "gate_control_left.act", lukeEnergySaveCylinderScript)
|
||||
if len(script.Triggers) != 1 || len(script.Behaviors) != 1 {
|
||||
t.Fatalf("triggers=%d behaviors=%d want 1/1", len(script.Triggers), len(script.Behaviors))
|
||||
}
|
||||
trigger := script.Triggers[0]
|
||||
if len(trigger.Conditions) != 1 ||
|
||||
trigger.Conditions[0] != (Condition{
|
||||
Kind: ConditionMonsterCount, MonsterID: 57156, Op: CompareLess, Value: 1,
|
||||
}) {
|
||||
t.Fatalf("conditions=%+v unsupported=%v", trigger.Conditions, trigger.Unsupported)
|
||||
}
|
||||
operations := script.Behaviors[0].Operations
|
||||
if len(operations) != 1 ||
|
||||
operations[0].Kind != OperationSymbolCalc ||
|
||||
operations[0].Symbol != "storage gate open" ||
|
||||
operations[0].Calc != "+" || operations[0].Value != 1 {
|
||||
t.Fatalf("operations=%+v", operations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAnimationOnlyScriptDrivesNothing(t *testing.T) {
|
||||
script := parseTestScript(t, "idle.act", "[MOTION]\n[BASE ANI]\n`../a.ani`\n[/MOTION]\n")
|
||||
if script.Drives() {
|
||||
t.Fatalf("animation-only script reported as driving: %+v", script)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user