服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
537 lines
18 KiB
Go
537 lines
18 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf16"
|
|
|
|
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
|
|
dnfequip "longheng.io/server/internal/modules/dnf/equip"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
currentDungeonCompanionProjectionSourceEnv = "DNF_COMPANION_P0_SOURCE_CHARACTER_ID"
|
|
currentDungeonCompanionProjectionOpcode = uint16(0x7F41)
|
|
currentDungeonCompanionProjectionMagic = uint32(0x314A5250) // PRJ1
|
|
currentDungeonCompanionProjectionVersion = byte(3)
|
|
currentDungeonCompanionProjectionFlagPresent = byte(1)
|
|
currentDungeonCompanionProjectionFlagAIPlan = byte(4)
|
|
currentDungeonCompanionProjectionHeaderSize = 72
|
|
currentDungeonCompanionProjectionMaxEquip = 32
|
|
currentDungeonCompanionProjectionMaxSkills = 128
|
|
currentDungeonCompanionProjectionMaxQuick = 16
|
|
currentDungeonCompanionProjectionObjectKey = uint16(0xFFFF)
|
|
currentDungeonCompanionMode0ObjectKeyOffset = 0x4C
|
|
currentDungeonCompanionMode1ObjectKeyOffset = 0x15
|
|
)
|
|
|
|
func currentDungeonCompanionProjectionSourceIDValue(value string) (uint16, bool) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return 0, false
|
|
}
|
|
parsed, err := strconv.ParseUint(value, 10, 16)
|
|
if err != nil || parsed == 0 || parsed >= uint64(^uint16(0)) ||
|
|
strconv.FormatUint(parsed, 10) != value {
|
|
return 0, false
|
|
}
|
|
return uint16(parsed), true
|
|
}
|
|
|
|
type currentDungeonCompanionProjectionSkillPair struct {
|
|
ID uint32
|
|
Level uint32
|
|
}
|
|
|
|
type currentDungeonCompanionProjectionAIPlan struct {
|
|
Behavior byte
|
|
ComboPresetHash byte
|
|
ClassID uint16
|
|
ComboEnabled bool
|
|
}
|
|
|
|
func currentDungeonCompanionProjectionAIPlanFor(
|
|
profile dnfcompanion.AIProfile,
|
|
job byte,
|
|
) currentDungeonCompanionProjectionAIPlan {
|
|
profile = dnfcompanion.NormalizeAIProfile(profile)
|
|
behavior := byte(1)
|
|
switch profile.Behavior {
|
|
case "aggressive":
|
|
behavior = 2
|
|
}
|
|
classID := profile.ClassID
|
|
if classID == 0 {
|
|
classID = uint16(job)
|
|
}
|
|
// Keep the packet compact; the full preset name remains in GM settings.
|
|
// The client needs a stable selector until the native action ABI is proved.
|
|
var hash byte = 216
|
|
for i := 0; i < len(profile.ComboPreset); i++ {
|
|
hash ^= profile.ComboPreset[i]
|
|
hash *= 167
|
|
}
|
|
return currentDungeonCompanionProjectionAIPlan{
|
|
Behavior: behavior,
|
|
ComboPresetHash: hash,
|
|
ClassID: classID,
|
|
ComboEnabled: profile.ComboEnabled,
|
|
}
|
|
}
|
|
|
|
func currentDungeonCompanionProjectionAIPlanWord(
|
|
plan currentDungeonCompanionProjectionAIPlan,
|
|
) uint32 {
|
|
flags := plan.Behavior & 0x03
|
|
if plan.ComboEnabled {
|
|
flags |= 0x04
|
|
}
|
|
return uint32(flags) |
|
|
uint32(plan.ComboPresetHash)<<8 |
|
|
uint32(plan.ClassID)<<16
|
|
}
|
|
|
|
func currentDungeonCompanionEquipmentIDs(
|
|
record dnfrepo.EquipmentRecord,
|
|
now time.Time,
|
|
) ([]uint32, error) {
|
|
if now.IsZero() {
|
|
return nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
type equipmentItem struct {
|
|
slot int16
|
|
id uint32
|
|
}
|
|
equipmentRows := make([]equipmentItem, 0, len(record.Entries))
|
|
slots := make(map[int16]struct{}, len(record.Entries))
|
|
for _, entry := range record.Entries {
|
|
if entry.SlotIndex < 0 || entry.SlotIndex > 255 ||
|
|
entry.ItemID <= 0 || entry.ItemID > int64(^uint32(0)) {
|
|
return nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
if _, exists := slots[entry.SlotIndex]; exists {
|
|
return nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
slots[entry.SlotIndex] = struct{}{}
|
|
if !entry.ExpireAt.IsZero() && !entry.ExpireAt.After(now) {
|
|
continue
|
|
}
|
|
itemID := entry.ItemID
|
|
if raw := strings.TrimSpace(entry.Extra[dnfequip.CloneAvatarDisplayItemIDExtraKey]); raw != "" {
|
|
value, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil || value <= 0 || value > int64(^uint32(0)) {
|
|
return nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
itemID = value
|
|
}
|
|
equipmentRows = append(equipmentRows, equipmentItem{
|
|
slot: entry.SlotIndex,
|
|
id: uint32(itemID),
|
|
})
|
|
}
|
|
sort.Slice(equipmentRows, func(i, j int) bool {
|
|
if equipmentRows[i].slot != equipmentRows[j].slot {
|
|
return equipmentRows[i].slot < equipmentRows[j].slot
|
|
}
|
|
return equipmentRows[i].id < equipmentRows[j].id
|
|
})
|
|
if len(equipmentRows) > currentDungeonCompanionProjectionMaxEquip {
|
|
return nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
equipmentIDs := make([]uint32, len(equipmentRows))
|
|
for i := range equipmentRows {
|
|
equipmentIDs[i] = equipmentRows[i].id
|
|
}
|
|
return equipmentIDs, nil
|
|
}
|
|
|
|
func buildCurrentDungeonCompanionProjectionInstanceState(
|
|
source dnfrepo.CompanionSourceState,
|
|
now time.Time,
|
|
) ([]uint32, []currentDungeonCompanionProjectionSkillPair, []currentDungeonCompanionProjectionSkillPair, error) {
|
|
if now.IsZero() || !source.HasEquipment || !source.HasSkill ||
|
|
source.Equipment.CharacterID != source.Source.CharacterID ||
|
|
source.Skill.CharacterID != source.Source.CharacterID {
|
|
return nil, nil, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
|
|
equipmentIDs, err := currentDungeonCompanionEquipmentIDs(
|
|
source.Equipment, now)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
skills := make([]currentDungeonCompanionProjectionSkillPair, 0, len(source.Skill.Skills))
|
|
for id, state := range source.Skill.Skills {
|
|
if !state.Enabled || state.Level <= 0 {
|
|
continue
|
|
}
|
|
if id <= 0 || id > int64(^uint32(0)) || state.Level > int(^uint16(0)) {
|
|
return nil, nil, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
skills = append(skills, currentDungeonCompanionProjectionSkillPair{
|
|
ID: uint32(id), Level: uint32(state.Level),
|
|
})
|
|
}
|
|
sort.Slice(skills, func(i, j int) bool { return skills[i].ID < skills[j].ID })
|
|
if len(skills) > currentDungeonCompanionProjectionMaxSkills {
|
|
return nil, nil, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
|
|
// The current EXE owns two six-slot quickbars in tree zero:
|
|
// 0..5 => A/S/D/F/G/H
|
|
// 198..203 => Q/W/E/R/T/Y
|
|
// Preserve all 12 positions, including empty slots. Compressing the vector
|
|
// would shift later keyboard bindings whenever a middle slot is empty.
|
|
quickSlotOrder := [...]int{0, 1, 2, 3, 4, 5, 198, 199, 200, 201, 202, 203}
|
|
quick := make([]currentDungeonCompanionProjectionSkillPair, len(quickSlotOrder))
|
|
if layout := source.Skill.Layouts[0]; layout != nil {
|
|
for index, slot := range quickSlotOrder {
|
|
rawID, exists := layout[slot]
|
|
if !exists || rawID == 0 {
|
|
continue
|
|
}
|
|
id := uint32(rawID)
|
|
state, ok := source.Skill.Skills[int64(id)]
|
|
if !ok || !state.Enabled || state.Level <= 0 {
|
|
continue
|
|
}
|
|
quick[index] = currentDungeonCompanionProjectionSkillPair{
|
|
ID: id, Level: uint32(state.Level),
|
|
}
|
|
}
|
|
}
|
|
// An empty quickbar is authoritative. Do not fill it from the learned
|
|
// skill vector: learned skills in the tree are not necessarily placed in a
|
|
// quickbar, and doing so makes the companion cast skills the player did not
|
|
// put in any of the twelve visible slots. Keep all twelve positions (and
|
|
// their empty 0/0 pairs) so the client key-program indexes remain stable.
|
|
if len(quick) > currentDungeonCompanionProjectionMaxQuick {
|
|
return nil, nil, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
return equipmentIDs, skills, quick, nil
|
|
}
|
|
|
|
func buildCurrentDungeonCompanionProjectionEnvelope(
|
|
source dnfrepo.CompanionSourceState,
|
|
selection dnfcompanion.Selection,
|
|
now time.Time,
|
|
options ...currentDungeonCompanionProjectionOptions,
|
|
) (dnfcompanion.VisualProjection, []byte, error) {
|
|
projection, err := dnfcompanion.BuildVisualProjection(source, selection, now)
|
|
if err != nil {
|
|
return dnfcompanion.VisualProjection{}, nil, err
|
|
}
|
|
var option currentDungeonCompanionProjectionOptions
|
|
if len(options) > 0 {
|
|
option = options[0]
|
|
}
|
|
profile := option.AIProfile
|
|
if profile.Behavior == "" {
|
|
profile = dnfcompanion.DefaultAIProfile(uint16(projection.Job))
|
|
}
|
|
if err := profile.Validate(); err != nil {
|
|
return dnfcompanion.VisualProjection{}, nil, err
|
|
}
|
|
if option.EffectiveLevel > 0 {
|
|
if option.EffectiveLevel > 90 {
|
|
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
projection.Level = option.EffectiveLevel
|
|
}
|
|
character := dnfrepo.CloneCharacter(source.Source)
|
|
if option.EffectiveLevel > 0 {
|
|
character.Level = option.EffectiveLevel
|
|
}
|
|
equipment := dnfrepo.CloneEquipment(source.Equipment)
|
|
rows, err := buildCurrentActorMode0AppearanceSummaryFromEquipment(equipment)
|
|
if err != nil {
|
|
return dnfcompanion.VisualProjection{}, nil, err
|
|
}
|
|
character.Roster.Entry.EquipSummary = cloneRosterEquipSummary(rows)
|
|
|
|
mode0 := buildCurrentSceneObjectListBodyInContext(
|
|
currentDungeonCompanionProjectionObjectKey,
|
|
character, true, character.Name, currentSceneObjectContext)
|
|
// mode0 always owns source identity and complete current-EXE appearance.
|
|
// Balanced mode optionally adds one mode1 stat blob after APC registration;
|
|
// original mode keeps mode1 empty and therefore remains byte-compatible.
|
|
mode1 := append([]byte(nil), option.Mode1...)
|
|
if len(mode0) <= currentDungeonCompanionMode0ObjectKeyOffset+1 ||
|
|
mode0[0] != 0 ||
|
|
binary.LittleEndian.Uint16(
|
|
mode0[currentDungeonCompanionMode0ObjectKeyOffset:currentDungeonCompanionMode0ObjectKeyOffset+2]) !=
|
|
currentDungeonCompanionProjectionObjectKey {
|
|
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
if len(mode1) != 0 &&
|
|
(len(mode1) <= currentDungeonCompanionMode1ObjectKeyOffset+1 ||
|
|
mode1[0] != 1 ||
|
|
binary.LittleEndian.Uint16(
|
|
mode1[currentDungeonCompanionMode1ObjectKeyOffset:currentDungeonCompanionMode1ObjectKeyOffset+2]) !=
|
|
currentDungeonCompanionProjectionObjectKey) {
|
|
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
|
|
fingerprintHex := projection.Fingerprint()
|
|
fingerprint, err := hex.DecodeString(fingerprintHex)
|
|
if err != nil || len(fingerprint) != 32 {
|
|
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
if option.EffectiveLevel > 0 || len(mode1) != 0 {
|
|
hash := sha256.New()
|
|
_, _ = hash.Write(fingerprint)
|
|
_, _ = hash.Write([]byte{byte(option.EffectiveLevel), 1})
|
|
_, _ = hash.Write(mode1)
|
|
fingerprint = hash.Sum(nil)
|
|
}
|
|
revision := binary.LittleEndian.Uint64(fingerprint[:8])
|
|
if revision == 0 {
|
|
revision = 1
|
|
}
|
|
|
|
nameUTF16 := utf16.Encode([]rune(projection.Name))
|
|
if len(nameUTF16) == 0 || len(nameUTF16) > 32 {
|
|
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
equipmentIDs, skills, quickSkills, err :=
|
|
buildCurrentDungeonCompanionProjectionInstanceState(source, now)
|
|
// These IDs populate the copied native AIC before APC construction. They
|
|
// select the actual worn weapon and avatars, including clone appearances.
|
|
// Use the frozen source/runtime equipment; substituting owner gear strips
|
|
// source avatars and can give the APC a weapon for the wrong profession.
|
|
if err != nil || projection.Level < 1 || projection.Level > 90 {
|
|
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
|
|
}
|
|
dynamicBytes := len(nameUTF16)*2 +
|
|
len(equipmentIDs)*4 +
|
|
len(skills)*8 +
|
|
len(quickSkills)*8
|
|
body := make([]byte,
|
|
currentDungeonCompanionProjectionHeaderSize+
|
|
dynamicBytes+len(mode0)+len(mode1))
|
|
binary.LittleEndian.PutUint32(
|
|
body[0:4], currentDungeonCompanionProjectionMagic)
|
|
body[4] = currentDungeonCompanionProjectionVersion
|
|
body[5] = currentDungeonCompanionProjectionFlagPresent |
|
|
currentDungeonCompanionProjectionFlagAIPlan
|
|
binary.LittleEndian.PutUint16(body[6:8],
|
|
uint16(mustProjectionCharacterID(projection.SourceCharacterID)))
|
|
binary.LittleEndian.PutUint64(body[8:16], revision)
|
|
binary.LittleEndian.PutUint32(body[16:20], uint32(len(mode0)))
|
|
binary.LittleEndian.PutUint32(body[20:24], uint32(len(mode1)))
|
|
copy(body[24:56], fingerprint)
|
|
body[56] = projection.Job
|
|
body[57] = projection.GrowType
|
|
binary.LittleEndian.PutUint16(body[58:60], uint16(len(nameUTF16)))
|
|
binary.LittleEndian.PutUint16(body[60:62], uint16(len(equipmentIDs)))
|
|
binary.LittleEndian.PutUint16(body[62:64], uint16(projection.Level))
|
|
binary.LittleEndian.PutUint16(body[64:66], uint16(len(skills)))
|
|
binary.LittleEndian.PutUint16(body[66:68], uint16(len(quickSkills)))
|
|
binary.LittleEndian.PutUint32(body[68:72],
|
|
currentDungeonCompanionProjectionAIPlanWord(
|
|
currentDungeonCompanionProjectionAIPlanFor(profile, projection.Job)))
|
|
cursor := currentDungeonCompanionProjectionHeaderSize
|
|
for _, unit := range nameUTF16 {
|
|
binary.LittleEndian.PutUint16(body[cursor:cursor+2], unit)
|
|
cursor += 2
|
|
}
|
|
for _, itemID := range equipmentIDs {
|
|
binary.LittleEndian.PutUint32(body[cursor:cursor+4], itemID)
|
|
cursor += 4
|
|
}
|
|
for _, skill := range skills {
|
|
binary.LittleEndian.PutUint32(body[cursor:cursor+4], skill.ID)
|
|
binary.LittleEndian.PutUint32(body[cursor+4:cursor+8], skill.Level)
|
|
cursor += 8
|
|
}
|
|
for _, skill := range quickSkills {
|
|
binary.LittleEndian.PutUint32(body[cursor:cursor+4], skill.ID)
|
|
binary.LittleEndian.PutUint32(body[cursor+4:cursor+8], skill.Level)
|
|
cursor += 8
|
|
}
|
|
copy(body[cursor:cursor+len(mode0)], mode0)
|
|
cursor += len(mode0)
|
|
copy(body[cursor:], mode1)
|
|
return projection, body, nil
|
|
}
|
|
|
|
func mustProjectionCharacterID(value string) uint64 {
|
|
parsed, _ := strconv.ParseUint(value, 10, 16)
|
|
return parsed
|
|
}
|
|
|
|
func (s *Service) currentDungeonCompanionProjectionSourceID(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
repos dnfrepo.Group,
|
|
) (uint16, string, bool, error) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return 0, "", false, nil
|
|
}
|
|
ownerID := strconv.FormatUint(
|
|
uint64(session.selectedCharacterID), 10)
|
|
if repos.Settings != nil {
|
|
scope, err := dnfcompanion.SettingsScope(ownerID)
|
|
if err != nil {
|
|
return 0, "settings", false, err
|
|
}
|
|
record, found, err := repos.Settings.Load(ctx, scope)
|
|
if err != nil {
|
|
return 0, "settings", false, err
|
|
}
|
|
if found {
|
|
source, selected, err :=
|
|
dnfcompanion.SourceCharacterIDFromSettings(
|
|
record, ownerID)
|
|
if err != nil {
|
|
return 0, "settings", false, err
|
|
}
|
|
if !selected {
|
|
return 0, "settings", false, nil
|
|
}
|
|
sourceID, ok :=
|
|
currentDungeonCompanionProjectionSourceIDValue(source)
|
|
if !ok {
|
|
return 0, "settings", false,
|
|
dnfcompanion.ErrSelection
|
|
}
|
|
return sourceID, "settings", true, nil
|
|
}
|
|
}
|
|
|
|
sourceID, enabled :=
|
|
currentDungeonCompanionProjectionSourceIDValue(
|
|
os.Getenv(
|
|
currentDungeonCompanionProjectionSourceEnv))
|
|
if !enabled {
|
|
return 0, "", false, nil
|
|
}
|
|
return sourceID, "environment_fallback", true, nil
|
|
}
|
|
|
|
func (s *Service) sendCurrentDungeonCompanionProjection(
|
|
session *gameSession,
|
|
source string,
|
|
) error {
|
|
if s == nil || session == nil ||
|
|
currentDungeonCompanionP0CaptureBudget() == 0 ||
|
|
session.selectedCharacterID == 0 {
|
|
return nil
|
|
}
|
|
repos, ok := s.repositoryGroup()
|
|
if !ok || repos.Character == nil {
|
|
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
|
|
"source", source,
|
|
"reason", "repository_unavailable")
|
|
return nil
|
|
}
|
|
selectionCtx, selectionCancel := context.WithTimeout(
|
|
context.Background(), currentRepositorySnapshotTimeout)
|
|
defer selectionCancel()
|
|
sourceID, selectionSource, enabled, selectionErr :=
|
|
s.currentDungeonCompanionProjectionSourceID(
|
|
selectionCtx, session, repos)
|
|
if selectionErr != nil {
|
|
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
|
|
"source", source,
|
|
"selection_source", selectionSource,
|
|
"reason", "source_selection_invalid",
|
|
"error", selectionErr)
|
|
return nil
|
|
}
|
|
if !enabled {
|
|
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
|
|
"source", source,
|
|
"selection_source", selectionSource,
|
|
"reason", "source_not_selected")
|
|
return nil
|
|
}
|
|
if sourceID == session.selectedCharacterID {
|
|
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
|
|
"source", source, "source_character_id", sourceID,
|
|
"selection_source", selectionSource,
|
|
"reason", "source_is_owner")
|
|
return nil
|
|
}
|
|
reader, ok := repos.Character.(dnfrepo.CompanionSourceReader)
|
|
if !ok {
|
|
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
|
|
"source", source, "source_character_id", sourceID,
|
|
"selection_source", selectionSource,
|
|
"reason", "consistent_reader_unavailable")
|
|
return nil
|
|
}
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if accountID == "" {
|
|
return nil
|
|
}
|
|
_, sourceOnline := s.onlineGameSession(sourceID)
|
|
ctx, cancel := context.WithTimeout(
|
|
context.Background(), currentRepositorySnapshotTimeout)
|
|
defer cancel()
|
|
state, err := reader.ReadCompanionSource(
|
|
ctx, accountID,
|
|
strconv.FormatUint(uint64(session.selectedCharacterID), 10),
|
|
strconv.FormatUint(uint64(sourceID), 10))
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
|
|
"source", source, "source_character_id", sourceID,
|
|
"selection_source", selectionSource,
|
|
"reason", "consistent_read_failed", "error", err)
|
|
return nil
|
|
}
|
|
selection := dnfcompanion.Selection{
|
|
AccountID: accountID,
|
|
OwnerCharacterID: strconv.FormatUint(
|
|
uint64(session.selectedCharacterID), 10),
|
|
SourceCharacterID: strconv.FormatUint(
|
|
uint64(sourceID), 10),
|
|
OnlineStateKnown: true,
|
|
SourceOnline: sourceOnline,
|
|
}
|
|
projection, body, err :=
|
|
buildCurrentDungeonCompanionProjectionEnvelope(
|
|
state, selection, s.gameplayNow())
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
|
|
"source", source, "source_character_id", sourceID,
|
|
"selection_source", selectionSource,
|
|
"source_online", sourceOnline,
|
|
"reason", "projection_invalid", "error", err)
|
|
return nil
|
|
}
|
|
weaponID := int64(0)
|
|
if projection.Weapon != nil {
|
|
weaponID = projection.Weapon.DisplayItemID
|
|
}
|
|
s.logGameEvent(session, "game-dungeon-companion-projection-send",
|
|
"source", source,
|
|
"selection_source", selectionSource,
|
|
"owner_character_id", session.selectedCharacterID,
|
|
"source_character_id", sourceID,
|
|
"name", projection.Name,
|
|
"job", projection.Job, "grow_type", projection.GrowType,
|
|
"level", projection.Level,
|
|
"weapon_item_id", weaponID,
|
|
"avatar_count", len(projection.Avatars),
|
|
"aic_equipment_count", int(binary.LittleEndian.Uint16(body[60:62])),
|
|
"aic_skill_count", int(binary.LittleEndian.Uint16(body[64:66])),
|
|
"aic_quick_skill_count", int(binary.LittleEndian.Uint16(body[66:68])),
|
|
"projection_hash", projection.Fingerprint(),
|
|
"placeholder_object_key", currentDungeonCompanionProjectionObjectKey,
|
|
"private_opcode", currentDungeonCompanionProjectionOpcode,
|
|
"body_len", len(body),
|
|
"template_mutation", false)
|
|
return s.sendGameUpperRawClass(
|
|
session, currentDungeonCompanionProjectionOpcode, body, 0)
|
|
}
|