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