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,353 @@
package dnfbridge
import (
"fmt"
"math"
"longheng.io/server/internal/modules/dnf/worldmap"
)
type currentDungeonPassiveObjectDropPlan struct {
ExtraEntries []currentDungeonStartMapExtraEntry
Drops []*runtimeDungeonDrop
StartObjectKey uint32
NextObjectKey uint32
StartSceneSlot uint16
NextSceneSlot uint16
StartDropRNG uint32
NextDropRNG uint32
ConfiguredRowCount int
}
// planCurrentDungeonPassiveObjectDrops performs the maze-level item roll
// without mutating runtime state. The caller commits the plan only after the
// corresponding op29 write succeeds.
func (s *Service) planCurrentDungeonPassiveObjectDrops(
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
startObjectKey uint32,
startDropRNG uint32,
) (currentDungeonPassiveObjectDropPlan, error) {
if session == nil || runtime == nil || runtime.Dungeon.ID <= 0 || runtime.MazeIndex < 0 || runtime.MazeIndex >= len(runtime.Dungeon.Mazes) {
return currentDungeonPassiveObjectDropPlan{}, errDungeonWorldMapUnavailable
}
if runtime.DropOwner != nil {
startObjectKey = runtime.DropOwner.nextObjectKeyAtOrAbove(startObjectKey)
}
if startObjectKey == 0 || startObjectKey > math.MaxUint16 {
return currentDungeonPassiveObjectDropPlan{}, fmt.Errorf("%w: next=%d", errDungeonDropObjectKeyRange, startObjectKey)
}
ownerActorObjectKey := currentSceneActorObjectKey(session.selectedCharacterID)
if ownerActorObjectKey == 0 {
return currentDungeonPassiveObjectDropPlan{}, errDungeonDropActorMismatch
}
nextSceneSlot := uint16(1)
if runtime.DropOwner != nil {
nextSceneSlot = runtime.DropOwner.nextSceneSlot
}
if nextSceneSlot == 0 {
return currentDungeonPassiveObjectDropPlan{}, fmt.Errorf("%w: next=%d", errDungeonDropSceneSlotRange, nextSceneSlot)
}
rows := runtime.Dungeon.Metadata.SpecialPassiveObjectItems
if len(rows) == 0 {
// Retain the old maze-level field for synthetic fixtures and historical
// serialized tables. Current real PVF parsing stores this definition on
// the dungeon, matching the client/reference ownership.
rows = runtime.Dungeon.Mazes[runtime.MazeIndex].SpecialPassiveObjectItems
}
plan := currentDungeonPassiveObjectDropPlan{
StartObjectKey: startObjectKey,
NextObjectKey: startObjectKey,
StartSceneSlot: nextSceneSlot,
NextSceneSlot: nextSceneSlot,
StartDropRNG: startDropRNG,
NextDropRNG: startDropRNG,
ConfiguredRowCount: len(rows),
}
if currentDungeonRuntimeIsPractice(runtime) {
return plan, nil
}
if len(rows) == 0 {
return plan, nil
}
monsterCatalog, err := s.dungeonMonsterCatalog()
if err != nil {
return currentDungeonPassiveObjectDropPlan{}, err
}
items, err := monsterCatalog.DropCatalog()
if err != nil {
return currentDungeonPassiveObjectDropPlan{}, err
}
groups := make(map[int64][]worldmap.SpecialPassiveObjectItem, len(rows))
for _, row := range rows {
if row.Index < 0 || row.Index > math.MaxUint8 || row.ItemID <= 0 || row.ItemID > math.MaxUint32 ||
row.DropRate < 0 || row.DropRate > currentDungeonDropDenominator {
return currentDungeonPassiveObjectDropPlan{}, fmt.Errorf(
"invalid special passive object item row: index=%d item=%d rate=%d",
row.Index, row.ItemID, row.DropRate,
)
}
groups[row.Index] = append(groups[row.Index], row)
}
rng := newCurrentDungeonDropLCG(startDropRNG)
roomKey := runtimeDungeonRoomKeyFromScene(scene)
skippedRandomAttempts := 0
// rollGroup performs one attempt against one reward group: a single
// 0..9999 roll compared with the group's cumulative drop rates yields at
// most one item (86JP PassiveObjectDropGenerator.SelectSpecificItem). The
// roll is consumed even when the group is empty, matching the reference.
rollGroup := func(groupIndex int64, passiveObjectIndex byte, attempt, attempts int, source string) error {
group := groups[groupIndex]
roll := rng.Next(currentDungeonDropDenominator)
selectedItemID := currentDungeonPassiveObjectGroupSelect(group, roll)
s.logGameEvent(session, "game-dungeon-passive-object-item-roll",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"group_index", groupIndex,
"group_item_count", len(group),
"passive_object_index", passiveObjectIndex,
"attempt", attempt,
"attempts", attempts,
"denominator", currentDungeonDropDenominator,
"roll", roll,
"selected_item_id", selectedItemID,
"source", source)
if selectedItemID <= 0 {
return nil
}
if len(plan.ExtraEntries) >= currentDungeonPassiveObjectMaxEntries {
return nil
}
definition, resolveErr := items.ResolveItem(uint32(selectedItemID))
if resolveErr != nil {
// 86JP counts an unpreparable intent and keeps the room alive; one
// bad PVF row must not block the whole dungeon entry.
s.logGameEvent(session, "game-dungeon-passive-object-item-unresolved",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"group_index", groupIndex,
"item_id", selectedItemID,
"error", resolveErr)
return nil
}
if plan.NextObjectKey >= math.MaxUint16 || plan.NextSceneSlot == math.MaxUint16 {
return fmt.Errorf(
"passive-object drop key range exhausted: object_key=%d scene_slot=%d",
plan.NextObjectKey, plan.NextSceneSlot,
)
}
amountOrQuality := uint32(1)
qualitySeed := uint32(0)
if definition.Kind == dungeonDropItemEquipment {
qualitySeed = rng.NextUint32()%currentEquipmentRandomQualitySeedCount + 1
amountOrQuality = qualitySeed
}
plan.ExtraEntries = append(plan.ExtraEntries, currentDungeonStartMapExtraEntry{
PassiveObjectIndex: passiveObjectIndex,
GlobalSequence: plan.NextObjectKey,
ItemID: definition.ItemID,
StackCount: amountOrQuality,
Endurance: definition.Durability,
})
plan.Drops = append(plan.Drops, &runtimeDungeonDrop{
ObjectKey: plan.NextObjectKey,
SceneSlot: plan.NextSceneSlot,
Item: definition,
Amount: 1,
QualitySeed: qualitySeed,
OwnerActorObjectKey: ownerActorObjectKey,
Room: roomKey,
Status: runtimeDungeonDropAvailable,
})
plan.NextObjectKey++
plan.NextSceneSlot++
return nil
}
// Map [special passive object] children of kind [item]: `[item] <group>
// <specific attempts> <random attempts> ...`. The group is rolled once per
// specific attempt; a level of -1 means no specific roll at all. The
// random-attempt branch (itemdropinfo_object.etc generic gold/equipment)
// is not implemented and is only counted for the log line.
for objectIndex, object := range scene.SpecialPassiveObjects {
for _, spawn := range object.Spawns {
if normalizeDungeonPVFSymbol(spawn.Kind) != "item" {
continue
}
if objectIndex > math.MaxUint8 {
return currentDungeonPassiveObjectDropPlan{}, fmt.Errorf("%w: special_object_index=%d", errDungeonExtendedActorIndexRange, objectIndex)
}
if spawn.Code < 0 || spawn.Code > math.MaxUint8 {
continue
}
if randomAttempts := currentDungeonPassiveObjectSignedByte(spawn.Params[0]); randomAttempts > 0 {
skippedRandomAttempts += randomAttempts
}
attempts := currentDungeonPassiveObjectSignedByte(spawn.Level)
for attempt := 0; attempt < attempts; attempt++ {
if err := rollGroup(spawn.Code, byte(objectIndex), attempt, attempts, "map_special_passive_object_item_spawn"); err != nil {
return currentDungeonPassiveObjectDropPlan{}, err
}
}
}
}
// Ordinary map passive objects whose definition declares a quest-item
// reward group keep the legacy group selector used by the current client
// (they are not projected as op29 type-9 actors) and roll once per
// instance.
for _, object := range scene.PassiveObjects {
group, known, groupErr := monsterCatalog.passiveObjectRewardGroup(object.ObjectID)
if groupErr != nil {
return currentDungeonPassiveObjectDropPlan{}, groupErr
}
if !known {
continue
}
if group < 0 || group > math.MaxUint8 {
return currentDungeonPassiveObjectDropPlan{}, fmt.Errorf("invalid ordinary passive-object reward group: object_id=%d group=%d", object.ObjectID, group)
}
if err := rollGroup(group, byte(group), 0, 1, "map_passive_object_quest_item_reward_group"); err != nil {
return currentDungeonPassiveObjectDropPlan{}, err
}
}
if skippedRandomAttempts > 0 {
s.logGameEvent(session, "game-dungeon-passive-object-item-random-attempts-skipped",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"random_attempts", skippedRandomAttempts,
"reason", "itemdropinfo_object_generic_branch_not_implemented")
}
plan.NextDropRNG = rng.Seed()
return plan, nil
}
// currentDungeonPassiveObjectMaxEntries is the op29 extra-entry count byte
// ceiling shared with the 86JP reference (MaxStartMapItems).
const currentDungeonPassiveObjectMaxEntries = 0xff
// currentDungeonPassiveObjectSignedByte decodes a map [item] child count the
// way the reference does: the PVF stores -1 for "none", and the native reader
// truncates the value to a signed byte.
func currentDungeonPassiveObjectSignedByte(value int64) int {
return int(int8(uint8(value)))
}
// currentDungeonPassiveObjectGroupSelect returns the item won by one roll
// against a group's cumulative drop rates, or 0 when the roll lands beyond
// the group's total (or the group is empty).
func currentDungeonPassiveObjectGroupSelect(group []worldmap.SpecialPassiveObjectItem, roll int) int64 {
cumulative := int64(0)
for _, row := range group {
if row.DropRate > 0 {
cumulative += row.DropRate
}
if int64(roll) < cumulative {
return row.ItemID
}
}
return 0
}
// currentDungeonPassiveObjectGroupOwners resolves every maze reward group to
// the byte that the current client uses to find the owning passive object.
// A special object's [item] code selects a reward group; it is not itself the
// op29 object index. Ordinary map passive objects keep the legacy group
// selector used by the current client because they are not projected as op29
// type-9 actors.
func currentDungeonPassiveObjectGroupOwners(
scene worldmap.DungeonRoomScene,
catalog *pvfDungeonMonsterCatalog,
) (map[int64][]byte, error) {
owners := make(map[int64][]byte)
for objectIndex, object := range scene.SpecialPassiveObjects {
groups := make(map[int64]struct{})
for _, spawn := range object.Spawns {
if normalizeDungeonPVFSymbol(spawn.Kind) != "item" || spawn.Code < 0 || spawn.Code > math.MaxUint8 {
continue
}
groups[spawn.Code] = struct{}{}
}
if len(groups) != 0 && objectIndex > math.MaxUint8 {
return nil, fmt.Errorf("%w: special_object_index=%d", errDungeonExtendedActorIndexRange, objectIndex)
}
for group := range groups {
owners[group] = append(owners[group], byte(objectIndex))
}
}
if len(scene.PassiveObjects) == 0 {
return owners, nil
}
if catalog == nil {
return nil, errDungeonMonsterCatalogUnavailable
}
for _, object := range scene.PassiveObjects {
group, known, err := catalog.passiveObjectRewardGroup(object.ObjectID)
if err != nil {
return nil, err
}
if known {
if group < 0 || group > math.MaxUint8 {
return nil, fmt.Errorf("invalid ordinary passive-object reward group: object_id=%d group=%d", object.ObjectID, group)
}
owners[group] = append(owners[group], byte(group))
}
}
return owners, nil
}
func commitCurrentDungeonPassiveObjectDrops(
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
plan currentDungeonPassiveObjectDropPlan,
) error {
if runtime == nil || runtime.Session == nil || runtime.Room == nil {
return errDungeonWorldMapUnavailable
}
visit, err := runtime.currentDungeonRoomVisit(scene)
if err != nil {
return err
}
if visit.PassiveObjectDropsPlanned {
return fmt.Errorf("%w: room=%s map=%d passive_object_drops_already_planned", errDungeonMoveRuntimeOwnerMismatch, scene.Coordinate, scene.Map.Map.ID)
}
if runtime.NextObjectKey > plan.StartObjectKey || visit.DropRNG != plan.StartDropRNG {
return fmt.Errorf(
"%w: passive_object_plan start_object=%d runtime_object=%d start_rng=%08x room_rng=%08x",
errDungeonMoveRuntimeOwnerMismatch,
plan.StartObjectKey, runtime.NextObjectKey, plan.StartDropRNG, visit.DropRNG,
)
}
// Target-room actor planning reserves the object-key range before the op29
// write, while the live runtime advances only after that write commits.
// Catch up to the reserved drop range without allowing any backwards move.
runtime.NextObjectKey = plan.StartObjectKey
owner := runtime.DropOwner
if owner == nil {
owner = newRuntimeDungeonDropOwner()
}
if owner.nextSceneSlot != plan.StartSceneSlot {
return fmt.Errorf(
"%w: passive_object_plan start_scene_slot=%d runtime_scene_slot=%d",
errDungeonMoveRuntimeOwnerMismatch, plan.StartSceneSlot, owner.nextSceneSlot,
)
}
if len(plan.Drops) != 0 {
if err := owner.registerBatch(plan.Drops, plan.NextSceneSlot); err != nil {
return err
}
runtime.DropOwner = owner
}
runtime.NextObjectKey = plan.NextObjectKey
visit.DropRNG = plan.NextDropRNG
visit.PassiveObjectExtraEntries = append([]currentDungeonStartMapExtraEntry(nil), plan.ExtraEntries...)
visit.PassiveObjectDropsPlanned = true
return nil
}