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

336 lines
13 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"math"
"strconv"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func (s *Service) handleCurrentGuildAura(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketSetGuildFlagAura)
if len(body) != 8 {
return s.sendGameUpperFailure(session, opcode, 19)
}
items, err := s.currentPVFItemCatalog()
if err != nil {
return s.sendGameUpperFailure(session, opcode, 19)
}
catalog, err := dnfguild.LoadCatalog(items.source)
if err != nil {
return s.sendGameUpperFailure(session, opcode, 19)
}
ctx, cancel := s.currentGuildContext()
defer cancel()
err = s.mutateCurrentGuild(ctx, func(repositories dnfrepo.Group, registry *dnfguild.Registry) error {
character, err := selectedGuildCharacter(ctx, repositories, session)
if err != nil || character.AccountID != s.accountIDForSession(session) {
return dnfguild.ErrCharacterNotFound
}
return registry.SetAura(character.CharacterID, binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:]), catalog, s.gameplayNow())
})
return s.finishCurrentGuildSettings(session, opcode, err, nil)
}
func (s *Service) transactCurrentGuildAssets(ctx context.Context, session *gameSession, apply func(dnfrepo.Group, *dnfguild.Registry, *dnfrepo.CharacterRecord) error) error {
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterSettlement == nil {
return dnfguild.ErrRepositoryUnavailable
}
characterID := strconv.Itoa(int(session.selectedCharacterID))
s.guildMu.Lock()
defer s.guildMu.Unlock()
return repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
character, err := selectedGuildCharacter(ctx, tx, session)
if err != nil {
return err
}
if character.AccountID != s.accountIDForSession(session) {
return dnfguild.ErrCharacterNotFound
}
registry, err := dnfguild.Load(ctx, tx.Settings)
if err != nil {
return err
}
if err := apply(tx, &registry, &character); err != nil {
return err
}
return dnfguild.Save(ctx, tx.Settings, registry, s.gameplayNow())
})
}
// op761's refusal codes. Handler 0x01D0EAD0 turns the code into a text id with
// `byte[0x1D0EBC0 + code - 9]` -> slot and `jmp [0x1D0EB8C + slot*4]`; every case
// body is a two-push stub whose first immediate is the text id. Decoded with
// D:\re_tools\content_codes.py (byte table 0x1D0EBC0, jump table 0x1D0EB8C):
//
// 9 -> 19489 只能购买1次永久性公会内容。 (content already owned)
// 10 -> 256 \n 错误 : 金币不足 (gold too low)
// 13 -> 19338 购买后公会仓库将超过上限,因此无法购买。 (generic / does not fit)
// 14 -> 19488 公会仓库升级错误
// 19 -> 76639 公会之契约只能购买30天。
// 20 -> 48428 超出可购买次数上限,无法继续购买。
// 22 -> 19336 公会积分不足。 (mileage too low)
// 36 -> 60120 您没有权限
// 38 -> 76638 当前公会成员扩张状态和公会之契约的类型不符。
// 105 -> 48421 只有购买前置公会成员扩张券后,才能购买该道具。
// 116 -> 48420 已使用的公会成员扩张券。
// 182 -> 19337 没有基地。
// everything else (slots 12) -> no toast at all
//
// The mapping used to answer 182 for ErrInsufficientMileage, so a guild whose 积分
// was too low was told 没有基地 whenever it bought a 公会内容 item -- the live
// symptom of 2026-09-12. A full bag has no code of its own in this table (255
// 物品栏空间不足 is not reachable through it), so it shares the generic refusal.
const (
currentGuildContentErrorAlreadyOwned = byte(9)
currentGuildContentErrorGold = byte(10)
currentGuildContentErrorGeneric = byte(13)
currentGuildContentErrorMileage = byte(22)
currentGuildContentErrorPermission = byte(36)
currentGuildContentErrorMemberState = byte(38)
)
func currentGuildContentError(err error) byte {
switch {
case errors.Is(err, dnfguild.ErrContentActive):
return currentGuildContentErrorAlreadyOwned
case errors.Is(err, dnfguild.ErrInsufficientMileage):
return currentGuildContentErrorMileage
case errors.Is(err, dnfguild.ErrPermissionDenied):
return currentGuildContentErrorPermission
case errors.Is(err, dnfguild.ErrMemberLimit):
return currentGuildContentErrorMemberState
case errors.Is(err, dnfguild.ErrInsufficientGold):
return currentGuildContentErrorGold
case errors.Is(err, errDungeonPickupInventoryFull):
return currentGuildContentErrorGeneric
default:
return currentGuildContentErrorGeneric
}
}
func (s *Service) handleCurrentGuildContentsPurchase(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketBuyGuildContents)
if len(body) < 8 {
return s.sendGameUpperFailure(session, opcode, 13)
}
name, next, valid := readCurrentRawDstr(body, 4)
if !valid || next != len(body) || len(name) != 0 {
return s.sendGameUpperFailure(session, opcode, 13)
}
contentID := binary.LittleEndian.Uint32(body)
items, err := s.currentPVFItemCatalog()
if err != nil {
return s.sendGameUpperFailure(session, opcode, 13)
}
catalog, err := dnfguild.LoadCatalog(items.source)
if err != nil {
return s.sendGameUpperFailure(session, opcode, 13)
}
content, found := catalog.Contents[contentID]
if !found {
return s.sendGameUpperFailure(session, opcode, 13)
}
var definition dungeonDropItemDefinition
if content.Type == 20 {
definition, err = items.ResolveItem(content.ItemID)
if err != nil || definition.Kind != dungeonDropItemStackable {
return s.sendGameUpperFailure(session, opcode, 13)
}
}
ctx, cancel := s.currentGuildContext()
defer cancel()
var update []currentItemListEntry
var gold int64
err = s.transactCurrentGuildAssets(ctx, session, func(tx dnfrepo.Group, registry *dnfguild.Registry, character *dnfrepo.CharacterRecord) error {
if _, err := registry.Purchase(character, content, s.gameplayNow()); err != nil {
return err
}
if content.Type == 20 {
inventory, found, err := tx.Inventory.Load(ctx, character.CharacterID)
if err != nil {
return err
}
if !found {
return dnfguild.ErrRepositoryUnavailable
}
if currentQuestDropHeldCount(inventory, int64(content.ItemID)) > 0 {
return dnfguild.ErrContentActive
}
slot, err := currentQuestDropGrantStack(&inventory, definition, 1)
if err != nil {
return err
}
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
stack := inventory.Slots[key]
stack.Extra["source"] = "guild_content_purchase"
stack.Extra["last_grant_source"] = "guild_content_purchase"
inventory.Slots[key] = stack
update = append(update, currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack))
if err := tx.Inventory.Save(ctx, inventory); err != nil {
return err
}
}
gold = character.Stats["gold"]
if content.Gold {
return dnfrepo.SaveCharacterFields(ctx, tx.Character, *character, dnfrepo.CharacterFieldStats)
}
return nil
})
if err != nil {
s.logGameEvent(session, "game-guild-content-purchase-rejected", "content_id", contentID, "error", err)
return s.sendGameUpperFailure(session, opcode, currentGuildContentError(err))
}
if err := s.sendGameUpperSuccess(session, opcode, nil); err != nil {
return err
}
if content.Gold {
if err := s.sendGameUpperRawClass(session, 14, buildCurrentGoldStateBody(gold), 0); err != nil {
return err
}
}
if len(update) > 0 {
if err := s.sendGameUpperRawClass(session, 14, buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, update), 0); err != nil {
return err
}
}
guild, err := s.currentGuildForSelectedCharacter(session)
if err != nil || guild == nil {
return err
}
s.logGameEvent(session, "game-guild-content-purchased", "content_id", contentID, "gold_after", gold, "mileage_after", guild.Mileage)
return s.publishCurrentGuildManagement(session, guild)
}
func (s *Service) handleCurrentGuildContractClaim(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketContractOfGuild)
// sub_F9A0B0 writes a packed 17-byte structure. Only its final u32 is
// business input (content type 21); the prefix is client transport state.
if len(body) != 17 || binary.LittleEndian.Uint32(body[13:]) != 21 {
return s.sendGameUpperFailure(session, opcode, 19)
}
items, err := s.currentPVFItemCatalog()
if err != nil {
return s.sendGameUpperFailure(session, opcode, 19)
}
catalog, err := dnfguild.LoadCatalog(items.source)
if err != nil {
return s.sendGameUpperFailure(session, opcode, 19)
}
definition, err := items.ResolveItem(catalog.ContractItem)
if err != nil || definition.Kind != dungeonDropItemStackable {
return s.sendGameUpperFailure(session, opcode, 19)
}
ctx, cancel := s.currentGuildContext()
defer cancel()
var update currentItemListEntry
err = s.transactCurrentGuildAssets(ctx, session, func(tx dnfrepo.Group, registry *dnfguild.Registry, character *dnfrepo.CharacterRecord) error {
if _, err := registry.ClaimContract(character.CharacterID, s.gameplayNow()); err != nil {
return err
}
inventory, found, err := tx.Inventory.Load(ctx, character.CharacterID)
if err != nil {
return err
}
if !found {
return dnfguild.ErrRepositoryUnavailable
}
slot, err := currentQuestDropGrantStack(&inventory, definition, catalog.ContractCount)
if err != nil {
return err
}
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
stack := inventory.Slots[key]
stack.Extra["source"] = "guild_contract"
stack.Extra["last_grant_source"] = "guild_contract"
inventory.Slots[key] = stack
update = currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
return tx.Inventory.Save(ctx, inventory)
})
if err != nil {
return s.sendGameUpperFailure(session, opcode, 19)
}
if err := s.sendGameUpperSuccess(session, opcode, nil); err != nil {
return err
}
return s.sendGameUpperRawClass(session, 14, buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, []currentItemListEntry{update}), 0)
}
func (s *Service) handleCurrentGuildRename(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketChangeGuildName)
if len(body) < 7 || body[2] != 0 {
return s.sendGameUpperFailure(session, opcode, 19)
}
slot := binary.LittleEndian.Uint16(body)
name, next, valid := parseCurrentGuildDSTR(body, 3)
if slot > math.MaxInt16 || !valid || next != len(body) || dnfguild.ValidateName(name) != nil {
return s.sendGameUpperFailure(session, opcode, 106)
}
items, err := s.currentPVFItemCatalog()
if err != nil {
return s.sendGameUpperFailure(session, opcode, 19)
}
catalog, err := dnfguild.LoadCatalog(items.source)
if err != nil {
return s.sendGameUpperFailure(session, opcode, 19)
}
ctx, cancel := s.currentGuildContext()
defer cancel()
var update currentItemListEntry
err = s.transactCurrentGuildAssets(ctx, session, func(tx dnfrepo.Group, registry *dnfguild.Registry, character *dnfrepo.CharacterRecord) error {
guild, member, found := registry.GuildForCharacter(character.CharacterID)
if !found || member.Grade != dnfguild.DefaultMasterGrade {
return dnfguild.ErrPermissionDenied
}
if !registry.NameAvailable(name) {
return dnfguild.ErrNameExists
}
inventory, found, err := tx.Inventory.Load(ctx, character.CharacterID)
if err != nil {
return err
}
if !found {
return dnfguild.ErrRepositoryUnavailable
}
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, int16(slot))
stack, found := inventory.Slots[key]
allowed := false
for _, c := range catalog.Contents {
if c.Type == 20 && stack.ItemID == int64(c.ItemID) {
allowed = true
}
}
if !found || !allowed || stack.Count != 1 || currentDungeonDiscardStackLocked(stack) || currentDungeonDiscardStackExpired(stack, s.gameplayNow()) {
return dnfguild.ErrInvalidContent
}
delete(inventory.Slots, key)
update.patchCore(int16(slot), math.MaxUint32, 0)
guild.Name, guild.UpdatedAt = name, s.gameplayNow()
return tx.Inventory.Save(ctx, inventory)
})
if err != nil {
code := byte(19)
if errors.Is(err, dnfguild.ErrNameExists) {
code = 107
}
return s.sendGameUpperFailure(session, opcode, code)
}
if err := s.sendGameUpperSuccess(session, opcode, nil); err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, 14, buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, []currentItemListEntry{update}), 0); err != nil {
return err
}
guild, err := s.currentGuildForSelectedCharacter(session)
if err != nil || guild == nil {
return err
}
return s.publishCurrentGuildManagement(session, guild)
}