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,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)
}
}