服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
336 lines
13 KiB
Go
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, ®istry, &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)
|
|
}
|