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,441 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user