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

442 lines
15 KiB
Go

package dnfbridge
import (
"sort"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/actionobject"
"longheng.io/server/internal/modules/dnf/worldmap"
)
// The dungeon-symbol runtime. A room's placed passive objects run `.act`
// scripts that count live monsters, read and write run-scoped "dungeon
// symbols" and open or close the room's gates.
//
// The client runs these scripts too, but only its own room-local half: it
// posts the `[NOTICE]` and applies `[GATE ENABLE] … 0` itself (live
// 2026-09-23: entering 能源储藏室 shows 「守门将的力量关闭了能量室的大门。」 in the
// screen centre and shuts the boss door), while the symbol the cylinders add
// to never reaches 2 on its side. So the server keeps the run's symbols, and
// when a script opens a gate it sets the client's pass-gate flag with op312.
// Notices stay the client's; the server posts none.
//
// Instances live for the whole run, not per visit. The shipped scripts depend on
// that: gate_control_gate.act ships its reopen trigger disabled and has the
// closing behavior switch it on, so an object rebuilt on every room entry with
// the symbol already satisfied would never reopen anything.
//
// Every instance is evaluated on every event, whichever room the player stands
// in. Symbol writes apply at once; a gate verdict is remembered per room and
// published whenever that room is (or becomes) the loaded one.
//
// op312 must reach the room it is for. Handler sub_1D8A520 writes
// byte [map+0xBFC] = 1 (sub_2359210) on whatever map is current when it runs;
// the local character's per-frame update (0x1BC0847) calls sub_1B8E200 while
// its latch byte [char+0x17C4] is 0, and that walks the room's objects calling
// vfunc +0x248(1) on every same-team one once the map flag is set, then sets the
// latch. The latch is cleared by event 0x25 of the character's message switch
// (0x2A44160), i.e. once per room load. Sent straight after op29 the flag can
// land on the room being left: live 2026-09-23 conn game-000899 got op312
// 0.27 s before its op37 and the door stayed shut, while game-000893 with the
// identical sequence walked in. So a room-entry gate is published after the
// room's FINISH_LOADING, on every visit, since a fresh map starts with the
// flag clear.
// runtimeDungeonActionObjectMaxPasses bounds the fixpoint loop: a behavior can
// satisfy another trigger's condition, so evaluation repeats until a pass fires
// nothing. `[LIMIT]` and the trigger switches make every shipped script settle
// in two or three passes.
const runtimeDungeonActionObjectMaxPasses = 8
type runtimeDungeonActionObjectTrigger struct {
Enabled bool
Fired int
}
type runtimeDungeonActionObjectInstance struct {
ObjectID int64
Room runtimeDungeonRoomKey
Script actionobject.Script
Triggers []runtimeDungeonActionObjectTrigger
// Destroyed records `[DESTROY]`: the object stops evaluating.
Destroyed bool
}
type runtimeDungeonActionObjectState struct {
Symbols map[string]int64
Instances []*runtimeDungeonActionObjectInstance
// instantiated marks rooms whose objects are already built.
instantiated map[runtimeDungeonRoomKey]bool
// gateOpen is the last `[GATE ENABLE]` verdict per room; absent means the
// scripts never touched that room's gates.
gateOpen map[runtimeDungeonRoomKey]bool
// gatePublished is the room whose open verdict already went to the wire
// for the current visit, so a re-evaluation does not resend it.
gatePublished map[runtimeDungeonRoomKey]bool
}
func newRuntimeDungeonActionObjectState() *runtimeDungeonActionObjectState {
return &runtimeDungeonActionObjectState{
Symbols: make(map[string]int64),
instantiated: make(map[runtimeDungeonRoomKey]bool),
gateOpen: make(map[runtimeDungeonRoomKey]bool),
gatePublished: make(map[runtimeDungeonRoomKey]bool),
}
}
func (runtime *runtimeDungeonState) actionObjectState() *runtimeDungeonActionObjectState {
if runtime.actionObjects == nil {
runtime.actionObjects = newRuntimeDungeonActionObjectState()
}
return runtime.actionObjects
}
// runtimeDungeonActionObjectEffects is what one evaluation wants on the wire.
type runtimeDungeonActionObjectEffects struct {
Notices []actionobject.Operation
Fired int
Symbols []string
GateOpen *bool
}
// instantiateActionObjectsLocked builds the instances for a room the first time
// it is seen. Objects without a driving script are skipped, so most rooms add
// nothing.
func (s *Service) instantiateActionObjectsLocked(
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
) {
state := runtime.actionObjectState()
key := runtimeDungeonRoomKeyFromScene(scene)
if state.instantiated[key] {
return
}
state.instantiated[key] = true
catalog, err := s.dungeonMonsterCatalog()
if err != nil {
s.logGameEvent(session, "game-dungeon-action-object-catalog-unavailable",
"dungeon_id", runtime.Dungeon.ID, "room", scene.Coordinate.String(), "error", err)
return
}
for _, object := range scene.PassiveObjects {
script, found, err := catalog.passiveObjectActionScript(object.ObjectID)
if err != nil {
s.logGameEvent(session, "game-dungeon-action-object-script-failed",
"dungeon_id", runtime.Dungeon.ID, "room", scene.Coordinate.String(),
"object_id", object.ObjectID, "error", err)
continue
}
if !found {
continue
}
instance := &runtimeDungeonActionObjectInstance{
ObjectID: object.ObjectID,
Room: key,
Script: script,
Triggers: make([]runtimeDungeonActionObjectTrigger, len(script.Triggers)),
}
unsupported := make([]string, 0)
for index, trigger := range script.Triggers {
instance.Triggers[index].Enabled = trigger.Enabled
unsupported = append(unsupported, trigger.Unsupported...)
}
for _, behavior := range script.Behaviors {
unsupported = append(unsupported, behavior.Unsupported...)
}
state.Instances = append(state.Instances, instance)
s.logGameEvent(session, "game-dungeon-action-object-instantiated",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"object_id", object.ObjectID,
"script", script.Path,
"triggers", len(script.Triggers),
"behaviors", len(script.Behaviors),
"unsupported", strings.Join(unsupported, ","))
}
}
// liveMonsterCount answers `[WHICH][MONSTER][CHECKUP][IS INDEX]` for one room:
// how many placed monsters of that index have not been reported dead.
func runtimeDungeonActionObjectLiveMonsterCount(
runtime *runtimeDungeonState,
key runtimeDungeonRoomKey,
monsterID int64,
) int64 {
var room *runtimeDungeonRoom
if runtime.Room != nil {
if snapshot := runtime.Room.Snapshot(); snapshot.MapID == key.MapID &&
snapshot.Coordinate.X == key.X && snapshot.Coordinate.Y == key.Y {
room = runtime.Room
}
}
if room == nil {
if visit, ok := runtime.Rooms[key]; ok && visit != nil {
room = visit.Room
}
}
if room == nil {
return 0
}
count := int64(0)
for _, monster := range room.Snapshot().Monsters {
if monster.Spawn.MonsterID == monsterID && monster.State != runtimeDungeonMonsterDefeated {
count++
}
}
return count
}
func (state *runtimeDungeonActionObjectState) conditionHolds(
runtime *runtimeDungeonState,
instance *runtimeDungeonActionObjectInstance,
condition actionobject.Condition,
) bool {
switch condition.Kind {
case actionobject.ConditionDungeonSymbol:
return condition.Op.Satisfied(state.Symbols[condition.Symbol], condition.Value)
case actionobject.ConditionMonsterCount:
count := runtimeDungeonActionObjectLiveMonsterCount(runtime, instance.Room, condition.MonsterID)
return condition.Op.Satisfied(count, condition.Value)
default:
return false
}
}
// evaluateActionObjectsLocked runs every instance to a fixpoint and returns the
// effects that concern the current room.
func (s *Service) evaluateActionObjectsLocked(
runtime *runtimeDungeonState,
current runtimeDungeonRoomKey,
) runtimeDungeonActionObjectEffects {
state := runtime.actionObjectState()
effects := runtimeDungeonActionObjectEffects{}
touched := make(map[string]struct{})
for pass := 0; pass < runtimeDungeonActionObjectMaxPasses; pass++ {
firedThisPass := 0
for _, instance := range state.Instances {
if instance.Destroyed {
continue
}
for index, trigger := range instance.Script.Triggers {
runtimeTrigger := &instance.Triggers[index]
if !runtimeTrigger.Enabled || (trigger.Limit > 0 && runtimeTrigger.Fired >= trigger.Limit) {
continue
}
if trigger.Behavior < 0 || trigger.Behavior >= len(instance.Script.Behaviors) {
continue
}
satisfied := true
for _, condition := range trigger.Conditions {
if !state.conditionHolds(runtime, instance, condition) {
satisfied = false
break
}
}
if !satisfied {
continue
}
runtimeTrigger.Fired++
firedThisPass++
state.runBehavior(instance, instance.Script.Behaviors[trigger.Behavior], current, &effects, touched)
if instance.Destroyed {
break
}
}
}
effects.Fired += firedThisPass
if firedThisPass == 0 {
break
}
}
for symbol := range touched {
effects.Symbols = append(effects.Symbols, symbol)
}
sort.Strings(effects.Symbols)
if open, known := state.gateOpen[current]; known {
value := open
effects.GateOpen = &value
}
return effects
}
func (state *runtimeDungeonActionObjectState) runBehavior(
instance *runtimeDungeonActionObjectInstance,
behavior actionobject.Behavior,
current runtimeDungeonRoomKey,
effects *runtimeDungeonActionObjectEffects,
touched map[string]struct{},
) {
for _, operation := range behavior.Operations {
switch operation.Kind {
case actionobject.OperationSymbolCalc:
switch operation.Calc {
case "-":
state.Symbols[operation.Symbol] -= operation.Value
default:
state.Symbols[operation.Symbol] += operation.Value
}
touched[operation.Symbol] = struct{}{}
case actionobject.OperationSymbolSet:
state.Symbols[operation.Symbol] = operation.Value
touched[operation.Symbol] = struct{}{}
case actionobject.OperationGateEnable:
state.gateOpen[instance.Room] = operation.GateEnabled
if !operation.GateEnabled {
// A re-close lets a later open publish again.
delete(state.gatePublished, instance.Room)
}
case actionobject.OperationNotice, actionobject.OperationCenterMessage:
if instance.Room == current {
effects.Notices = append(effects.Notices, operation)
}
case actionobject.OperationTriggerSwitch:
if operation.TriggerIndex >= 0 && operation.TriggerIndex < len(instance.Triggers) {
instance.Triggers[operation.TriggerIndex].Enabled = operation.TriggerOn
}
case actionobject.OperationDestroy:
instance.Destroyed = true
return
}
}
}
// runtimeDungeonActionObjectEvent says whether the event happened inside an
// already-loaded room (a death) or is a room being entered (op29 just sent,
// the client's op37 still to come).
type runtimeDungeonActionObjectEvent byte
const (
runtimeDungeonActionObjectRoomEntered runtimeDungeonActionObjectEvent = iota
runtimeDungeonActionObjectInRoom
)
// runCurrentDungeonActionObjectsLocked is the one evaluation entry point: it
// instantiates the scene's objects if needed and evaluates everything. A gate
// the current room is owed goes out at once only for an in-room event; a room
// entry leaves it to publishCurrentDungeonActionObjectGateAfterFinishLoading.
// Caller holds session.dungeon.mu. Presentation failures are logged, never
// returned: the gameplay event that got us here is done.
func (s *Service) runCurrentDungeonActionObjectsLocked(
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
event runtimeDungeonActionObjectEvent,
source string,
) {
if s == nil || session == nil || runtime == nil || runtime.Room == nil || len(scene.PassiveObjects) == 0 &&
(runtime.actionObjects == nil || len(runtime.actionObjects.Instances) == 0) {
return
}
s.instantiateActionObjectsLocked(session, runtime, scene)
state := runtime.actionObjectState()
if len(state.Instances) == 0 {
return
}
current := runtimeDungeonRoomKeyFromScene(scene)
if event == runtimeDungeonActionObjectRoomEntered {
// A new visit is a new client map with its pass-gate flag clear.
delete(state.gatePublished, current)
}
effects := s.evaluateActionObjectsLocked(runtime, current)
gatePublished := false
if event == runtimeDungeonActionObjectInRoom {
gatePublished = s.publishCurrentDungeonActionObjectGateLocked(session, runtime, scene, source)
}
if effects.Fired == 0 && !gatePublished {
return
}
symbols := make([]string, 0, len(state.Symbols))
for name, value := range state.Symbols {
symbols = append(symbols, name+"="+strconv.FormatInt(value, 10))
}
sort.Strings(symbols)
gate := "untouched"
if effects.GateOpen != nil {
gate = "closed"
if *effects.GateOpen {
gate = "open"
}
}
s.logGameEvent(session, "game-dungeon-action-object-evaluated",
"source", source,
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"triggers_fired", effects.Fired,
"symbols_touched", strings.Join(effects.Symbols, ","),
"symbols", strings.Join(symbols, ","),
"room_gate", gate,
"gate_published", gatePublished,
"client_notices", len(effects.Notices),
"instances", len(state.Instances))
}
// publishCurrentDungeonActionObjectGateLocked sends op312 when the loaded room's
// scripts say its gate is open and this visit has not had it yet. Caller holds
// session.dungeon.mu.
func (s *Service) publishCurrentDungeonActionObjectGateLocked(
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
source string,
) bool {
if runtime == nil || runtime.actionObjects == nil {
return false
}
state := runtime.actionObjects
current := runtimeDungeonRoomKeyFromScene(scene)
if !state.gateOpen[current] || state.gatePublished[current] {
return false
}
// op312 COMPLETE_CONDITION_PASS_GATE is the pass-gate flag the boss-entrance
// and trap-event conditions already set; see the file comment for how the
// client turns it into open doors. It carries no direction: an opened gate
// is the room's boss door, which every shipped `[GATE ENABLE] … 1` is.
if err := s.sendGameUpperRawClass(
session,
currentDungeonCompleteConditionPassGateMsgID,
buildCurrentDungeonCompleteConditionPassGateBody(),
0,
); err != nil {
s.logGameEvent(session, "game-dungeon-action-object-gate-publish-failed",
"source", source, "dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(), "error", err)
return false
}
state.gatePublished[current] = true
return true
}
// publishCurrentDungeonActionObjectGateAfterFinishLoading is the room-entry half
// of the gate: it runs once the client has reported the new room loaded, so the
// op312 flag lands on that room's map and not the one just left.
func (s *Service) publishCurrentDungeonActionObjectGateAfterFinishLoading(session *gameSession, source string) {
if s == nil || session == nil {
return
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
runtime := session.dungeon.runtime
if runtime == nil || runtime.Session == nil || runtime.actionObjects == nil {
return
}
scene, ok := runtime.Session.Scene()
if !ok {
return
}
if s.publishCurrentDungeonActionObjectGateLocked(session, runtime, scene, source) {
s.logGameEvent(session, "game-dungeon-action-object-gate-published",
"source", source,
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"response_opcode", currentDungeonCompleteConditionPassGateMsgID)
}
}