Files
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

954 lines
36 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const skillSwitchInventoryStateValue = "state_json"
var (
errSkillSwitchUnavailable = errors.New("skill switch inventory is unavailable")
errSkillSwitchInvalidItem = errors.New("skill switch inventory item is invalid")
errSkillSwitchInvalidSkill = errors.New("skill switch inventory skill is invalid")
)
type skillSwitchInventoryPosition struct {
EquipmentPart byte
ListType byte
Slot int16
ItemID uint32
ItemSerial uint32
Raw [currentItemListEntryWireSize]byte
}
type skillSwitchInventoryResolution struct {
Positions map[byte]skillSwitchInventoryPosition
ExactMatchCount int
FallbackMatchCount int
}
func skillSwitchInventoryScope(characterID string) string {
return "character:" + strings.TrimSpace(characterID) + ":skill_switch_inventory"
}
func loadSkillSwitchInventoryState(ctx context.Context, repo dnfrepo.SettingsRepository, characterID string) (skillSwitchInventoryState, error) {
if repo == nil || strings.TrimSpace(characterID) == "" {
return skillSwitchInventoryState{}, errSkillSwitchUnavailable
}
record, found, err := repo.Load(ctx, skillSwitchInventoryScope(characterID))
if err != nil {
return skillSwitchInventoryState{}, err
}
if !found {
return skillSwitchInventoryState{}, nil
}
raw := strings.TrimSpace(record.Values[skillSwitchInventoryStateValue])
if raw == "" {
return skillSwitchInventoryState{}, nil
}
var state skillSwitchInventoryState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
return skillSwitchInventoryState{}, fmt.Errorf("decode skill-switch state: %w", err)
}
return normalizeSkillSwitchInventoryState(state), nil
}
func saveSkillSwitchInventoryState(ctx context.Context, repo dnfrepo.SettingsRepository, characterID string, state skillSwitchInventoryState) error {
state = normalizeSkillSwitchInventoryState(state)
raw, err := json.Marshal(state)
if err != nil {
return err
}
return repo.Save(ctx, dnfrepo.SettingsRecord{
Scope: skillSwitchInventoryScope(characterID),
Values: map[string]string{skillSwitchInventoryStateValue: string(raw)},
UpdatedAt: time.Now().UTC(),
})
}
func normalizeSkillSwitchInventoryState(state skillSwitchInventoryState) skillSwitchInventoryState {
byPart := make(map[byte]skillSwitchBinding, len(state.Items))
usedSerial := make(map[uint32]byte, len(state.Items))
for _, item := range state.Items {
if item.EquipmentPart > 32 || item.ItemSerial == 0 || item.ItemID == 0 {
continue
}
if _, exists := usedSerial[item.ItemSerial]; exists {
continue
}
usedSerial[item.ItemSerial] = item.EquipmentPart
byPart[item.EquipmentPart] = item
}
state.Items = state.Items[:0]
for _, item := range byPart {
state.Items = append(state.Items, item)
}
sort.Slice(state.Items, func(i, j int) bool { return state.Items[i].EquipmentPart < state.Items[j].EquipmentPart })
return state
}
func replaceSkillSwitchBinding(state skillSwitchInventoryState, binding skillSwitchBinding) skillSwitchInventoryState {
items := make([]skillSwitchBinding, 0, len(state.Items)+1)
for _, item := range state.Items {
if item.EquipmentPart == binding.EquipmentPart || item.ItemSerial == binding.ItemSerial {
continue
}
items = append(items, item)
}
items = append(items, binding)
state.Items = items
return normalizeSkillSwitchInventoryState(state)
}
func removeSkillSwitchBinding(state skillSwitchInventoryState, part byte) skillSwitchInventoryState {
items := state.Items[:0]
for _, item := range state.Items {
if item.EquipmentPart != part {
items = append(items, item)
}
}
state.Items = items
return normalizeSkillSwitchInventoryState(state)
}
func skillSwitchItemSerial(extra map[string]string, raw []byte) uint32 {
for _, key := range []string{"item_serial", "item_uid", "serial", "instance_serial", "creature_serial_or_handle"} {
value := strings.TrimSpace(extra[key])
if value == "" {
continue
}
parsed, err := strconv.ParseUint(value, 10, 32)
if err == nil && parsed != 0 {
return uint32(parsed)
}
}
return 0
}
// currentSkillSwitchEquipmentSerial preserves the creature identity used by
// the current client. Creature equipment stores its serial/handle at raw+24;
// treating that value as a generic equipment serial makes the binding vanish
// on the following inventory snapshot.
func currentSkillSwitchEquipmentSerial(entry dnfrepo.EquipmentEntry) uint32 {
if value := currentEquippedCreatureInstance(entry); value != 0 {
return value
}
return skillSwitchItemSerial(entry.Extra, entry.RawEntry)
}
func currentSkillSwitchItemListEntryFromEquipment(equipped dnfrepo.EquipmentEntry) (currentItemListEntry, bool) {
if entry, ok := currentItemListEntryFromEquipment(equipped); ok {
return entry, true
}
if equipped.SlotIndex != 26 || currentEquippedCreatureInstance(equipped) == 0 {
return currentItemListEntry{}, false
}
// List-3 registration needs the canonical 0x77 item row, but slot 26 is
// deliberately excluded from ordinary actor-equipment construction. Supply
// only a transient actor-slot hint for this projection; durable equipment
// and the dedicated creature lifecycle remain unchanged.
if equipped.Extra == nil {
equipped.Extra = make(map[string]string, 1)
} else {
cloned := make(map[string]string, len(equipped.Extra)+1)
for key, value := range equipped.Extra {
cloned[key] = value
}
equipped.Extra = cloned
}
equipped.Extra["current_exe_equipment_type"] = "26"
return currentItemListEntryFromEquipment(equipped)
}
func assignSkillSwitchItemSerial(stack *dnfrepo.ItemStack, used map[uint32]struct{}) uint32 {
if stack == nil {
return 0
}
if serial := skillSwitchItemSerial(stack.Extra, stack.RawEntry); serial != 0 {
return serial
}
for serial := uint32(1); serial != 0; serial++ {
if _, exists := used[serial]; exists {
continue
}
if stack.Extra == nil {
stack.Extra = make(map[string]string, 2)
}
stack.Extra["item_serial"] = strconv.FormatUint(uint64(serial), 10)
return serial
}
return 0
}
func collectSkillSwitchSerials(inventory dnfrepo.InventoryRecord, equipment dnfrepo.EquipmentRecord) map[uint32]struct{} {
used := make(map[uint32]struct{})
for _, stack := range inventory.Slots {
if serial := skillSwitchItemSerial(stack.Extra, stack.RawEntry); serial != 0 {
used[serial] = struct{}{}
}
}
for _, stack := range inventory.Warehouse {
if serial := skillSwitchItemSerial(stack.Extra, stack.RawEntry); serial != 0 {
used[serial] = struct{}{}
}
}
for _, entry := range equipment.Entries {
if serial := currentSkillSwitchEquipmentSerial(entry); serial != 0 {
used[serial] = struct{}{}
}
}
return used
}
func (s *Service) handleSkillSwitchInventory(session *gameSession, body []byte) error {
request, action, parseErr := decodeSkillSwitchInventoryRequest(body)
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
if parseErr != nil {
s.logGameEvent(session, "game-skill-switch-inventory-rejected", "body_len", len(body), "reason", parseErr)
if err := s.sendGameUpperRawClass(session, skillSwitchInventoryCmdWire, nil, 0); err != nil {
return err
}
return s.sendSkillSwitchInventoryAllData(ctx, session)
}
mutationErr := s.mutateSkillSwitchInventory(ctx, session, request, action)
if mutationErr != nil {
s.logGameEvent(session, "game-skill-switch-inventory-rejected", "action", action, "part", request.EquipmentPart, "list_type", request.ListType, "slot", request.Slot, "skill_id", request.SkillID, "reason", mutationErr)
}
if err := s.sendGameUpperRawClass(session, skillSwitchInventoryCmdWire, nil, 0); err != nil {
return err
}
if action == skillSwitchSetItem || action == skillSwitchRemoveItem {
return s.sendSkillSwitchInventoryDelta(ctx, session, []byte{request.EquipmentPart})
}
return s.sendSkillSwitchInventoryAllData(ctx, session)
}
func (s *Service) mutateSkillSwitchInventory(ctx context.Context, session *gameSession, request skillSwitchInventoryRequest, action skillSwitchInventoryAction) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return errSkillSwitchUnavailable
}
s.skillSwitchInventoryMu.Lock()
defer s.skillSwitchInventoryMu.Unlock()
repos, ok := s.repositoryGroup()
if !ok || repos.Settings == nil || repos.Character == nil || repos.Skill == nil || repos.Inventory == nil || repos.Equipment == nil {
return errSkillSwitchUnavailable
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
character, found, err := repos.Character.Load(ctx, characterID)
if err != nil || !found || character.CharacterID != characterID || strings.TrimSpace(character.AccountID) != strings.TrimSpace(s.accountIDForSession(session)) {
return errSkillSwitchUnavailable
}
state, err := loadSkillSwitchInventoryState(ctx, repos.Settings, characterID)
if err != nil {
return err
}
switch action {
case skillSwitchSetSkill:
skillID := request.SkillID
if skillID == math.MaxUint16 {
skillID = 0
}
if skillID != 0 {
skills, found, loadErr := repos.Skill.Load(ctx, characterID)
if loadErr != nil || !found {
return errSkillSwitchInvalidSkill
}
learned, ok := skillSwitchActiveTreeSkills(skills, character)[int64(skillID)]
if !ok || !learned.Enabled || learned.Level <= 0 {
return errSkillSwitchInvalidSkill
}
allowed, whitelistErr := s.skillSwitchSkillAllowedByPVF(character, skillID)
if whitelistErr != nil || !allowed {
return errors.Join(errSkillSwitchInvalidSkill, whitelistErr)
}
}
state.SkillID = skillID
case skillSwitchRemoveItem:
state = removeSkillSwitchBinding(state, request.EquipmentPart)
case skillSwitchSetItem:
binding, err := s.skillSwitchBindingForRequest(ctx, repos, characterID, request)
if err != nil {
return err
}
state = replaceSkillSwitchBinding(state, binding)
default:
return errSkillSwitchUnavailable
}
return saveSkillSwitchInventoryState(ctx, repos.Settings, characterID, state)
}
func (s *Service) skillSwitchSkillAllowedByPVF(character dnfrepo.CharacterRecord, skillID uint16) (bool, error) {
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return false, err
}
return skillSwitchSkillAllowedByPVFSource(archive, character, skillID)
}
func skillSwitchSkillAllowedByPVFSource(source initialEquipmentTextSource, character dnfrepo.CharacterRecord, skillID uint16) (bool, error) {
candidates, err := skillSwitchSupportSkillsByPVFSource(source, character)
if err != nil {
return false, err
}
for _, candidate := range candidates {
if candidate == skillID {
return true, nil
}
}
return false, nil
}
func (s *Service) skillSwitchSupportSkillsByPVF(character dnfrepo.CharacterRecord) ([]uint16, error) {
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return nil, err
}
return skillSwitchSupportSkillsByPVFSource(archive, character)
}
// skillSwitchSupportSkillsByPVFSource returns, in PVF order, the buff skills
// the BUFF-enhancement (换装) window offers this character's job/grow type:
// the `[skill]` block that follows its `[character type]` entry in
// skill/switchingsupportskill.etc (priest grow 1 lists `7 53 45 258`, i.e.
// 勇气祝福, 天使祝福, 荣誉祝福, ...).
func skillSwitchSupportSkillsByPVFSource(source initialEquipmentTextSource, character dnfrepo.CharacterRecord) ([]uint16, error) {
if source == nil {
return nil, errSkillSwitchInvalidSkill
}
job, ok := characterJobByte(character)
if !ok {
return nil, errSkillSwitchInvalidSkill
}
jobTag, err := currentRentalCharacterJobTag(source, job)
if err != nil {
return nil, err
}
text, err := source.ReadText("skill/switchingsupportskill.etc")
if err != nil {
return nil, err
}
document, err := dnfpvf.Parse("skill/switchingsupportskill.etc", text)
if err != nil {
return nil, err
}
wantJob := skillSwitchJobTag(jobTag)
wantGrow := int(numericCharacterStatValue(character, "grow_type")) & 0x0f
for idx, section := range document.Sections {
if !strings.EqualFold(strings.TrimSpace(section.Name), "character type") {
continue
}
profileJob, profileGrow, valid := skillSwitchCharacterTypeTokens(document.Tokens[section.Start:section.End])
if !valid || skillSwitchJobTag(profileJob) != wantJob || profileGrow != wantGrow {
continue
}
var candidates []uint16
for next := idx + 1; next < len(document.Sections); next++ {
nextSection := document.Sections[next]
name := strings.ToLower(strings.TrimSpace(nextSection.Name))
if name == "character type" {
break
}
if name != "skill" {
continue
}
for _, token := range document.Tokens[nextSection.Start:nextSection.End] {
if token.Kind == dnfpvf.TokenInt && token.Int > 0 && token.Int <= math.MaxUint16 {
candidates = append(candidates, uint16(token.Int))
}
}
}
return candidates, nil
}
return nil, nil
}
// defaultSkillSwitchSupportSkill picks the buff skill the BUFF-enhancement
// record should name when the player has never chosen one: the first PVF
// candidate the character has actually learned.
//
// The current client computes the buff it applies to *party members* from
// this record, while the caster's own buff uses the live skill. With an empty
// record (skill 0, no items - what every never-registered buffer received) the
// caster's client sent teammates a level-0 buff: live 2026-09-06, a crusader
// with 勇气祝福 Lv12 (skill 7, op19) whose op1369 was `00 00 00` buffed its
// teammate at level 0, whereas the working reference capture's buffer carried
// a registration (`0e 00 11 ...`, skill 14 + 17 items) and its teammates got
// the right level. The default is a projection only; the persisted state stays
// untouched so a later explicit choice from the 换装 window wins.
func defaultSkillSwitchSupportSkill(candidates []uint16, skills map[int64]dnfrepo.SkillState) uint16 {
for _, candidate := range candidates {
learned, ok := skills[int64(candidate)]
if ok && learned.Enabled && learned.Level > 0 {
return candidate
}
}
return 0
}
// skillSwitchActiveTreeSkills returns the character's active skill-tree rows.
// The durable record keeps tree 0 in Skills and alternate trees in Trees; the
// buff skill may live on either plan, so registration checks must read the
// active tree instead of always reading tree 0.
func skillSwitchActiveTreeSkills(skills dnfrepo.SkillRecord, character dnfrepo.CharacterRecord) map[int64]dnfrepo.SkillState {
tree := currentSkillInfoTreeIndex
if active, err := currentSceneActiveSkillTree(character); err == nil {
tree = int(active)
}
if state, ok := dnfrepo.SkillTree(skills, tree); ok {
return state.Skills
}
return skills.Skills
}
func skillSwitchJobTag(value string) string {
value = strings.TrimSpace(value)
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
value = strings.TrimSpace(value[1 : len(value)-1])
}
return currentRentalJobTag(value)
}
func skillSwitchCharacterTypeTokens(tokens []dnfpvf.Token) (string, int, bool) {
job := ""
grow := -1
for _, token := range tokens {
switch {
case job == "" && (token.Kind == dnfpvf.TokenString || token.Kind == dnfpvf.TokenIdent):
job = token.Value
case token.Kind == dnfpvf.TokenInt && grow < 0:
grow = int(token.Int)
}
}
return job, grow, job != "" && grow >= 0 && grow <= 5
}
func (s *Service) skillSwitchBindingForRequest(ctx context.Context, repos dnfrepo.Group, characterID string, request skillSwitchInventoryRequest) (skillSwitchBinding, error) {
if !skillSwitchListTypeSupported(request.ListType, request.EquipmentPart, request.Slot) {
return skillSwitchBinding{}, errSkillSwitchInvalidItem
}
inventory, found, err := repos.Inventory.Load(ctx, characterID)
if err != nil || !found {
return skillSwitchBinding{}, errSkillSwitchInvalidItem
}
equipment, _, err := repos.Equipment.Load(ctx, characterID)
if err != nil {
return skillSwitchBinding{}, errSkillSwitchInvalidItem
}
used := collectSkillSwitchSerials(inventory, equipment)
if request.ListType == 3 || request.ListType == 17 {
entry, ok := equipment.Entries[strconv.Itoa(int(request.Slot))]
if !ok || entry.ItemID <= 0 || entry.ItemID > math.MaxUint32 || entry.SlotIndex != int16(request.EquipmentPart) {
return skillSwitchBinding{}, errSkillSwitchInvalidItem
}
if err := s.validateSkillSwitchItemPart(uint32(entry.ItemID), request.EquipmentPart); err != nil {
return skillSwitchBinding{}, err
}
serial := currentSkillSwitchEquipmentSerial(entry)
if serial == 0 {
for candidate := uint32(1); candidate != 0; candidate++ {
if _, exists := used[candidate]; exists {
continue
}
serial = candidate
if entry.Extra == nil {
entry.Extra = make(map[string]string, 2)
}
entry.Extra["item_serial"] = strconv.FormatUint(uint64(serial), 10)
equipment.Entries[strconv.Itoa(int(request.Slot))] = entry
if err := repos.Equipment.Save(ctx, equipment); err != nil {
return skillSwitchBinding{}, err
}
break
}
}
if serial == 0 {
return skillSwitchBinding{}, errSkillSwitchInvalidItem
}
return skillSwitchBinding{EquipmentPart: request.EquipmentPart, ItemSerial: serial, ItemID: uint32(entry.ItemID)}, nil
}
var items map[string]dnfrepo.ItemStack
if request.ListType == 2 {
items = inventory.Warehouse
} else {
items = inventory.Slots
}
key := fmt.Sprintf("%d:%d", request.ListType, request.Slot)
stack, ok := items[key]
if !ok || stack.ItemID <= 0 || stack.ItemID > math.MaxUint32 {
return skillSwitchBinding{}, errSkillSwitchInvalidItem
}
if err := s.validateSkillSwitchItemPart(uint32(stack.ItemID), request.EquipmentPart); err != nil {
return skillSwitchBinding{}, err
}
serialWasMissing := skillSwitchItemSerial(stack.Extra, stack.RawEntry) == 0
serial := assignSkillSwitchItemSerial(&stack, used)
if serial == 0 {
return skillSwitchBinding{}, errSkillSwitchInvalidItem
}
if serialWasMissing {
if request.ListType == 2 {
inventory.Warehouse[key] = stack
} else {
inventory.Slots[key] = stack
}
if err := repos.Inventory.Save(ctx, inventory); err != nil {
return skillSwitchBinding{}, err
}
}
return skillSwitchBinding{EquipmentPart: request.EquipmentPart, ItemSerial: serial, ItemID: uint32(stack.ItemID)}, nil
}
func (s *Service) validateSkillSwitchItemPart(itemID uint32, part byte) error {
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return errors.Join(errSkillSwitchInvalidItem, err)
}
definition, err := catalog.ResolveItem(itemID)
if err != nil || strings.TrimSpace(definition.EquipmentType) == "" {
return errors.Join(errSkillSwitchInvalidItem, err)
}
// [creature] deliberately remains outside the ordinary equipment-placement
// map because pet movement has its own lifecycle. BUFF registration still
// accepts the equipped creature at actor slot 26.
if part == 26 && strings.EqualFold(strings.TrimSpace(definition.EquipmentType), "[creature]") {
return nil
}
rule, ok := currentEquipmentPlacementRuleForPVFType(definition.EquipmentType)
if !ok || rule.targetSlot < 0 || rule.targetSlot > 32 || byte(rule.targetSlot) != part {
return errSkillSwitchInvalidItem
}
return nil
}
func skillSwitchListTypeSupported(listType, part byte, slot uint16) bool {
switch listType {
case 0, 2:
return part <= 32
case 1:
return part <= 10
case 3, 17:
return slot <= 32 && byte(slot) == part
case 7:
return part >= 26 && part <= 29
case 38:
return part == 32
default:
return false
}
}
func (s *Service) currentSkillSwitchInventorySnapshot(ctx context.Context, session *gameSession) (skillSwitchInventoryState, map[byte]skillSwitchInventoryPosition, error) {
state, resolution, err := s.currentSkillSwitchInventorySnapshotWithResolution(ctx, session)
return state, resolution.Positions, err
}
func (s *Service) currentSkillSwitchInventorySnapshotWithResolution(ctx context.Context, session *gameSession) (skillSwitchInventoryState, skillSwitchInventoryResolution, error) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return skillSwitchInventoryState{}, skillSwitchInventoryResolution{}, errSkillSwitchUnavailable
}
repos, ok := s.repositoryGroup()
if !ok || repos.Settings == nil || repos.Inventory == nil || repos.Equipment == nil {
return skillSwitchInventoryState{}, skillSwitchInventoryResolution{}, errSkillSwitchUnavailable
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
state, err := loadSkillSwitchInventoryState(ctx, repos.Settings, characterID)
if err != nil {
return skillSwitchInventoryState{}, skillSwitchInventoryResolution{}, err
}
stateDirty := false
projectedSkillID := uint16(0)
if repos.Skill != nil {
if skills, found, skillErr := repos.Skill.Load(ctx, characterID); skillErr == nil && found {
if state.SkillID != 0 {
var character dnfrepo.CharacterRecord
if repos.Character != nil {
if loaded, charFound, charErr := repos.Character.Load(ctx, characterID); charErr == nil && charFound {
character = loaded
}
}
activeSkills := skillSwitchActiveTreeSkills(skills, character)
learned, ok := activeSkills[int64(state.SkillID)]
if !ok || !learned.Enabled || learned.Level <= 0 {
activeTree := currentSkillInfoTreeIndex
if active, treeErr := currentSceneActiveSkillTree(character); treeErr == nil {
activeTree = int(active)
}
s.logGameEvent(session, "game-skill-switch-inventory-skill-invalidated",
"char_id", characterID,
"skill_id", state.SkillID,
"active_tree", activeTree,
"learned_found", ok,
"learned_enabled", learned.Enabled,
"learned_level", learned.Level,
"tree0_level", skills.Skills[int64(state.SkillID)].Level,
"reason", "registered_buff_skill_not_learned_on_active_tree")
state.SkillID = 0
stateDirty = true
}
}
if state.SkillID == 0 && repos.Character != nil {
projectedSkillID = s.defaultSkillSwitchSupportSkillFor(ctx, session, repos, characterID, skills)
}
}
}
inventory, _, err := repos.Inventory.Load(ctx, characterID)
if err != nil {
return state, skillSwitchInventoryResolution{}, err
}
equipment, _, err := repos.Equipment.Load(ctx, characterID)
if err != nil {
return state, skillSwitchInventoryResolution{}, err
}
resolution := resolveSkillSwitchBindings(state, inventory, equipment)
if stateDirty {
if err := saveSkillSwitchInventoryState(ctx, repos.Settings, characterID, state); err != nil {
return state, resolution, err
}
}
// Applied after the durable save on purpose: the default is what the
// client sees, never what the player chose.
if state.SkillID == 0 && projectedSkillID != 0 {
state.SkillID = projectedSkillID
}
return state, resolution, nil
}
func (s *Service) defaultSkillSwitchSupportSkillFor(
ctx context.Context,
session *gameSession,
repos dnfrepo.Group,
characterID string,
skills dnfrepo.SkillRecord,
) uint16 {
character, found, err := repos.Character.Load(ctx, characterID)
if err != nil || !found {
return 0
}
candidates, err := s.skillSwitchSupportSkillsByPVF(character)
if err != nil {
s.logGameEvent(session, "game-skill-switch-default-support-skill-deferred",
"char_id", characterID,
"error", err)
return 0
}
skillID := defaultSkillSwitchSupportSkill(candidates, skillSwitchActiveTreeSkills(skills, character))
if skillID != 0 {
s.logGameEvent(session, "game-skill-switch-default-support-skill",
"char_id", characterID,
"skill_id", skillID,
"candidates", candidates,
"reason", "no_registered_support_skill_current_client_applies_party_buff_from_this_record")
}
return skillID
}
func locateSkillSwitchBindings(state skillSwitchInventoryState, inventory dnfrepo.InventoryRecord, equipment dnfrepo.EquipmentRecord) map[byte]skillSwitchInventoryPosition {
return resolveSkillSwitchBindings(state, inventory, equipment).Positions
}
func resolveSkillSwitchBindings(state skillSwitchInventoryState, inventory dnfrepo.InventoryRecord, equipment dnfrepo.EquipmentRecord) skillSwitchInventoryResolution {
positions := make(map[byte]skillSwitchInventoryPosition)
resolution := skillSwitchInventoryResolution{Positions: positions}
for _, binding := range state.Items {
exactMatches := make([]skillSwitchInventoryPosition, 0, 1)
fallbackMatches := make([]skillSwitchInventoryPosition, 0, 1)
for key, stack := range inventory.Slots {
listType, slot, ok := parseSceneInventorySlotKey(key)
if !ok || !skillSwitchListTypeSupported(listType, binding.EquipmentPart, uint16(slot)) || uint32(stack.ItemID) != binding.ItemID {
continue
}
entry := currentItemListEntryFromStack(listType, slot, stack)
position := skillSwitchInventoryPosition{EquipmentPart: binding.EquipmentPart, ListType: listType, Slot: slot, ItemID: binding.ItemID, ItemSerial: binding.ItemSerial, Raw: entry.data}
if skillSwitchItemSerial(stack.Extra, stack.RawEntry) == binding.ItemSerial {
exactMatches = append(exactMatches, position)
} else if skillSwitchItemSerial(stack.Extra, stack.RawEntry) == 0 {
fallbackMatches = append(fallbackMatches, position)
}
}
for key, stack := range inventory.Warehouse {
_, slot, ok := parseSceneInventorySlotKey(key)
if !ok || uint32(stack.ItemID) != binding.ItemID {
continue
}
entry := currentItemListEntryFromStack(2, slot, stack)
position := skillSwitchInventoryPosition{EquipmentPart: binding.EquipmentPart, ListType: 2, Slot: slot, ItemID: binding.ItemID, ItemSerial: binding.ItemSerial, Raw: entry.data}
if skillSwitchItemSerial(stack.Extra, stack.RawEntry) == binding.ItemSerial {
exactMatches = append(exactMatches, position)
} else if skillSwitchItemSerial(stack.Extra, stack.RawEntry) == 0 {
fallbackMatches = append(fallbackMatches, position)
}
}
for _, equipped := range equipment.Entries {
if uint32(equipped.ItemID) != binding.ItemID {
continue
}
entry, ok := currentSkillSwitchItemListEntryFromEquipment(equipped)
if !ok {
continue
}
position := skillSwitchInventoryPosition{EquipmentPart: binding.EquipmentPart, ListType: 3, Slot: equipped.SlotIndex, ItemID: binding.ItemID, ItemSerial: binding.ItemSerial, Raw: entry.data}
if currentSkillSwitchEquipmentSerial(equipped) == binding.ItemSerial {
exactMatches = append(exactMatches, position)
} else if currentSkillSwitchEquipmentSerial(equipped) == 0 {
fallbackMatches = append(fallbackMatches, position)
}
}
if len(exactMatches) == 1 {
positions[binding.EquipmentPart] = exactMatches[0]
resolution.ExactMatchCount++
} else if len(exactMatches) == 0 && len(fallbackMatches) == 1 {
positions[binding.EquipmentPart] = fallbackMatches[0]
resolution.FallbackMatchCount++
}
}
return resolution
}
func (s *Service) sendSkillSwitchInventoryDelta(ctx context.Context, session *gameSession, parts []byte) error {
_, positions, err := s.currentSkillSwitchInventorySnapshot(ctx, session)
if err != nil {
positions = nil
}
body := buildSkillSwitchDeltaPositionBody(positions, parts)
return s.sendGameUpperRawClass(session, skillSwitchInventoryDeltaWire, body, 0)
}
func (s *Service) sendSkillSwitchInventoryAllData(ctx context.Context, session *gameSession) error {
state, positions, err := s.currentSkillSwitchInventorySnapshot(ctx, session)
if err != nil {
state = skillSwitchInventoryState{}
positions = nil
}
return s.sendGameUpperRawClass(session, skillSwitchInventoryAllDataWire, buildSkillSwitchAllDataBody(state, positions), 0)
}
// sendSkillSwitchInventoryLocationRefresh follows a registered equipment
// instance after op19 movement or an inventory sort. Durable registration owns
// the item template plus the server-assigned instance serial, never the grid
// cell; rebuilding op559 from the committed repositories prevents the client
// from retaining the old list/slot until the player registers the item again.
//
// Characters without registered items receive no extra packet. This keeps the
// ordinary and skill-only move paths byte-for-byte unchanged because their
// registration tables contain no item position that could become stale.
func (s *Service) sendSkillSwitchInventoryLocationRefresh(session *gameSession, source string) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
state, resolution, err := s.currentSkillSwitchInventorySnapshotWithResolution(ctx, session)
if err != nil {
return err
}
if len(state.Items) == 0 {
return nil
}
body := buildSkillSwitchAllDataBody(state, resolution.Positions)
s.logGameEvent(session, "game-skill-switch-inventory-item-location-refresh-send",
"source", source,
"char_id", session.selectedCharacterID,
"msg_id", skillSwitchInventoryAllDataWire,
"registered_item_count", len(state.Items),
"resolved_item_count", len(resolution.Positions),
"exact_match_count", resolution.ExactMatchCount,
"fallback_match_count", resolution.FallbackMatchCount,
"wire_item_count", body[2],
"reason", "durable_item_serial_resolved_to_committed_dynamic_list_and_slot_after_item_move")
return s.sendGameUpperRawClass(session, skillSwitchInventoryAllDataWire, body, 0)
}
func (s *Service) sendInitialSkillSwitchInventoryAllData(ctx context.Context, session *gameSession, source string) error {
return s.sendInitialSkillSwitchInventoryAllDataWithRefresh(ctx, session, source, false)
}
// sendSkillSwitchInventoryAllDataRefresh replays the durable BUFF registration
// projection after a dungeon actor is rebuilt. The client clears its scene-
// local registration view during the transition, while the character
// generation remains unchanged, so the login-generation gate cannot be used
// here.
func (s *Service) sendSkillSwitchInventoryAllDataRefresh(ctx context.Context, session *gameSession, source string) error {
return s.sendInitialSkillSwitchInventoryAllDataWithRefresh(ctx, session, source, true)
}
func (s *Service) sendInitialSkillSwitchInventoryAllDataWithRefresh(ctx context.Context, session *gameSession, source string, refresh bool) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
if !refresh && session.skillSwitchInventoryAllDataCharacterID == session.selectedCharacterID &&
session.skillSwitchInventoryAllDataGeneration == session.characterGeneration {
return nil
}
state, resolution, err := s.currentSkillSwitchInventorySnapshotWithResolution(ctx, session)
if err != nil {
s.logGameEvent(session, "game-skill-switch-inventory-initial-refresh-deferred",
"source", source,
"char_id", session.selectedCharacterID,
"character_generation", session.characterGeneration,
"error", err)
return err
}
chronicleRefreshRows, err := s.sendSkillSwitchChronicleOptionRefreshes(session, resolution.Positions, source)
if err != nil {
return err
}
body := buildSkillSwitchAllDataBody(state, resolution.Positions)
reason := "current_exe_mode1_does_not_reset_buff_registration_ui_after_character_change"
if refresh {
reason = "current_exe_dungeon_mode1_clears_scene_local_buff_registration_ui"
}
s.logGameEvent(session, "game-skill-switch-inventory-initial-refresh-send",
"source", source,
"char_id", session.selectedCharacterID,
"character_generation", session.characterGeneration,
"msg_id", skillSwitchInventoryAllDataWire,
"state_item_count", len(state.Items),
"resolved_item_count", len(resolution.Positions),
"exact_match_count", resolution.ExactMatchCount,
"fallback_match_count", resolution.FallbackMatchCount,
"chronicle_item_refresh_rows", chronicleRefreshRows,
"wire_item_count", body[2],
"skill_id", state.SkillID,
"body_len", len(body),
"reason", reason)
if err := s.sendGameUpperRawClass(session, skillSwitchInventoryAllDataWire, body, 0); err != nil {
return err
}
session.skillSwitchInventoryAllDataCharacterID = session.selectedCharacterID
session.skillSwitchInventoryAllDataGeneration = session.characterGeneration
return nil
}
// sendSkillSwitchChronicleOptionRefreshes hydrates the item objects referenced
// by op559 before the BUFF-registration table is rebuilt. The current client
// does not carry Chronicle aura attributes in op559 itself; it resolves the
// green/red/blue skill modifier from raw+0x27 on the referenced list-0/list-2/
// list-3 item. Scene reconstruction replaces those client-side item objects,
// so replaying only the durable positions preserves the slots but loses the
// percentage modifier until a later inventory refresh or relog.
//
// Keep this narrowly scoped to repository-proven Chronicle option vectors.
// Ordinary registered equipment is left on the existing mode1/op13 path and
// no full inventory replay is introduced.
func (s *Service) sendSkillSwitchChronicleOptionRefreshes(
session *gameSession,
positions map[byte]skillSwitchInventoryPosition,
source string,
) (int, error) {
type slotEntry struct {
slot int16
entry currentItemListEntry
}
byList := make(map[byte]map[int16]currentItemListEntry)
for _, position := range positions {
if position.ListType != 0 && position.ListType != 2 && position.ListType != 3 {
continue
}
if !currentSkillSwitchHasChronicleOptionVector(position.Raw) {
continue
}
if byList[position.ListType] == nil {
byList[position.ListType] = make(map[int16]currentItemListEntry)
}
entry := currentItemListEntry{data: position.Raw}
byList[position.ListType][position.Slot] = entry
}
listTypes := make([]int, 0, len(byList))
for listType := range byList {
listTypes = append(listTypes, int(listType))
}
sort.Ints(listTypes)
totalRows := 0
for _, listTypeValue := range listTypes {
listType := byte(listTypeValue)
rows := make([]slotEntry, 0, len(byList[listType]))
for slot, entry := range byList[listType] {
rows = append(rows, slotEntry{slot: slot, entry: entry})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].slot < rows[j].slot })
entries := make([]currentItemListEntry, 0, len(rows))
for _, row := range rows {
entries = append(entries, row.entry)
}
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketWalkoutPartyMember),
buildCurrentItemUpdateBody(listType, entries),
0,
); err != nil {
return totalRows, err
}
totalRows += len(entries)
s.logGameEvent(session, "game-skill-switch-chronicle-item-refresh-send",
"source", source,
"char_id", session.selectedCharacterID,
"msg_id", uint16(dnfenum.CmdPacketWalkoutPartyMember),
"list_type", listType,
"row_count", len(entries),
"sequence", "repository_raw77_chronicle_rows_before_op559")
}
return totalRows, nil
}
func currentSkillSwitchHasChronicleOptionVector(raw [currentItemListEntryWireSize]byte) bool {
const (
optionCountOffset = 0x27
optionIDOffset = 0x28
optionCapacity = 2
)
count := int(raw[optionCountOffset])
if count < 1 || count > optionCapacity {
return false
}
for index := 0; index < count; index++ {
optionID := binary.LittleEndian.Uint32(raw[optionIDOffset+index*4 : optionIDOffset+(index+1)*4])
if optionID < currentChronicleCanonicalAuraBase || optionID > currentChronicleCanonicalAuraBase+2 {
return false
}
}
return true
}
func buildSkillSwitchDeltaPositionBody(positions map[byte]skillSwitchInventoryPosition, parts []byte) []byte {
seen := make(map[byte]struct{}, len(parts))
ordered := append([]byte(nil), parts...)
sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] })
result := make([]byte, 1, 1+len(ordered)*5)
for _, part := range ordered {
if _, ok := seen[part]; ok || part > 32 {
continue
}
seen[part] = struct{}{}
if position, ok := positions[part]; ok {
result = append(result, 1, part, position.ListType, 0, 0)
binaryPutUint16(result[len(result)-2:], uint16(position.Slot))
} else {
result = append(result, 0, part, skillSwitchClearListType, 0xff, 0xff)
}
}
result[0] = byte((len(result) - 1) / 5)
return result
}
func binaryPutUint16(dst []byte, value uint16) { dst[0] = byte(value); dst[1] = byte(value >> 8) }