服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1036 lines
40 KiB
Go
1036 lines
40 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
currentGameOptionMainBodySize = 178
|
|
currentGameOptionQuickChatBodySize = 300
|
|
currentGameOptionSaveHeaderLen = 4
|
|
currentGameOptionQuickChatSaveBodySize = 1 + currentGameOptionSaveHeaderLen + currentGameOptionQuickChatBodySize
|
|
currentGameOptionSaveBodySize = currentGameOptionSaveHeaderLen + currentGameOptionMainBodySize
|
|
currentGameOptionValueKey = "main_body_base64"
|
|
currentGameOptionKeymapValueKey = "keymap_body_base64"
|
|
currentGameOptionQuickChat0ValueKey = "quickchat_0_body_base64"
|
|
currentGameOptionQuickChat1ValueKey = "quickchat_1_body_base64"
|
|
currentGameOptionKeymapMaxBodySize = 4096
|
|
currentGameOptionMsgID uint16 = 0x00ad
|
|
currentGameOptionKeymapMsgID uint16 = 0x01c6
|
|
currentGameOptionKeymapMode = byte(0)
|
|
currentGameOptionRapidFireKeymapID = "I01"
|
|
currentGameOptionRapidFireIndex = 50
|
|
currentGameOptionRapidFireOffset = currentGameOptionRapidFireIndex * 2
|
|
currentGameOptionNativeApplyGracePeriod = 2 * time.Second
|
|
currentGameOptionJobAwakenEffectIndex = 1
|
|
currentGameOptionJobAwakenEffectOffset = currentGameOptionJobAwakenEffectIndex * 2
|
|
currentGameOptionJobAwakenEffectBit byte = 0x02
|
|
currentGameOptionShowAllAvatarIndex = 55
|
|
currentGameOptionShowAllAvatarOffset = currentGameOptionShowAllAvatarIndex * 2
|
|
currentGameOptionHideAllAvatarBit byte = 0x08
|
|
currentGameOptionUserStateStat = "user_state_bits"
|
|
)
|
|
|
|
func currentGameOptionScope(accountID string) string {
|
|
return "account:" + strings.TrimSpace(accountID) + ":game_option"
|
|
}
|
|
|
|
func validCurrentGameOptionSaveBody(body []byte) bool {
|
|
return len(body) == currentGameOptionSaveBodySize &&
|
|
binary.LittleEndian.Uint32(body[:currentGameOptionSaveHeaderLen]) == currentGameOptionMainBodySize
|
|
}
|
|
|
|
func validCurrentGameOptionMainBody(body []byte) bool {
|
|
return len(body) == currentGameOptionMainBodySize
|
|
}
|
|
|
|
func validCurrentGameOptionKeymapBody(body []byte) bool {
|
|
return len(body) > 0 && len(body) <= currentGameOptionKeymapMaxBodySize
|
|
}
|
|
|
|
func validCurrentGameOptionKeymapSaveBody(body []byte) bool {
|
|
return len(body) > currentGameOptionSaveHeaderLen &&
|
|
len(body) <= currentGameOptionSaveHeaderLen+currentGameOptionKeymapMaxBodySize &&
|
|
binary.LittleEndian.Uint32(body[:currentGameOptionSaveHeaderLen]) == uint32(len(body)-currentGameOptionSaveHeaderLen)
|
|
}
|
|
|
|
// currentGameOptionJobAwakenEffectEnabled reads zero-based type197 option 1.
|
|
// Live current-EXE saves proved that this word follows the setting checkbox.
|
|
func currentGameOptionJobAwakenEffectEnabled(main []byte) (bool, bool) {
|
|
if !validCurrentGameOptionMainBody(main) {
|
|
return false, false
|
|
}
|
|
return binary.LittleEndian.Uint16(
|
|
main[currentGameOptionJobAwakenEffectOffset:currentGameOptionJobAwakenEffectOffset+2],
|
|
) != 0, true
|
|
}
|
|
|
|
func currentGameOptionJobAwakenEffectUserState(current byte, enabled bool) byte {
|
|
if enabled {
|
|
return current | currentGameOptionJobAwakenEffectBit
|
|
}
|
|
return current &^ currentGameOptionJobAwakenEffectBit
|
|
}
|
|
|
|
// currentGameOptionShowAllAvatarEnabled reads zero-based type197 option 55.
|
|
// The corresponding visibility bit is inverted: bit 3 means hide all avatars.
|
|
func currentGameOptionShowAllAvatarEnabled(main []byte) (bool, bool) {
|
|
if !validCurrentGameOptionMainBody(main) {
|
|
return false, false
|
|
}
|
|
return binary.LittleEndian.Uint16(
|
|
main[currentGameOptionShowAllAvatarOffset:currentGameOptionShowAllAvatarOffset+2],
|
|
) != 0, true
|
|
}
|
|
|
|
// currentGameOptionRapidFireEnabled reads zero-based type197 option 50.
|
|
// Three independent live current-EXE saves toggled 普通攻击连发 and changed
|
|
// only this u16 (0 <-> 1). I01 in type198 is the ordinary-attack binding; it
|
|
// is necessary to arm rapid fire, but is not the persisted on/off switch.
|
|
func currentGameOptionRapidFireEnabled(main []byte) (bool, bool) {
|
|
if !validCurrentGameOptionMainBody(main) {
|
|
return false, false
|
|
}
|
|
return binary.LittleEndian.Uint16(
|
|
main[currentGameOptionRapidFireOffset:currentGameOptionRapidFireOffset+2],
|
|
) != 0, true
|
|
}
|
|
|
|
func currentGameOptionVisibilityUserState(current byte, advanced, showAllAvatar bool) byte {
|
|
current = currentGameOptionJobAwakenEffectUserState(current, advanced)
|
|
if showAllAvatar {
|
|
return current &^ currentGameOptionHideAllAvatarBit
|
|
}
|
|
return current | currentGameOptionHideAllAvatarBit
|
|
}
|
|
|
|
type currentGameOptionState struct {
|
|
main []byte
|
|
keymap []byte
|
|
quickChat [2][]byte
|
|
}
|
|
|
|
func decodeCurrentGameOptionValue(record dnfrepo.SettingsRecord, key string, valid func([]byte) bool) ([]byte, bool) {
|
|
encoded := strings.TrimSpace(record.Values[key])
|
|
if encoded == "" {
|
|
return nil, false
|
|
}
|
|
body, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil || !valid(body) {
|
|
return nil, false
|
|
}
|
|
return append([]byte(nil), body...), true
|
|
}
|
|
|
|
func loadCurrentGameOptionState(
|
|
ctx context.Context,
|
|
repository dnfrepo.SettingsRepository,
|
|
accountID string,
|
|
) (currentGameOptionState, bool, error) {
|
|
if repository == nil || strings.TrimSpace(accountID) == "" {
|
|
return currentGameOptionState{}, false, nil
|
|
}
|
|
record, found, err := repository.Load(ctx, currentGameOptionScope(accountID))
|
|
if err != nil || !found {
|
|
return currentGameOptionState{}, found, err
|
|
}
|
|
main, mainFound := decodeCurrentGameOptionValue(record, currentGameOptionValueKey, validCurrentGameOptionMainBody)
|
|
keymap, keymapFound := decodeCurrentGameOptionValue(record, currentGameOptionKeymapValueKey, validCurrentGameOptionKeymapBody)
|
|
quickChat0, quickChat0Found := decodeCurrentGameOptionValue(record, currentGameOptionQuickChat0ValueKey, validCurrentGameOptionQuickChatBody)
|
|
quickChat1, quickChat1Found := decodeCurrentGameOptionValue(record, currentGameOptionQuickChat1ValueKey, validCurrentGameOptionQuickChatBody)
|
|
return currentGameOptionState{
|
|
main: main,
|
|
keymap: keymap,
|
|
quickChat: [2][]byte{quickChat0, quickChat1},
|
|
}, mainFound || keymapFound || quickChat0Found || quickChat1Found, nil
|
|
}
|
|
|
|
func loadCurrentGameOption(
|
|
ctx context.Context,
|
|
repository dnfrepo.SettingsRepository,
|
|
accountID string,
|
|
) ([]byte, bool, error) {
|
|
state, _, err := loadCurrentGameOptionState(ctx, repository, accountID)
|
|
return state.main, len(state.main) != 0, err
|
|
}
|
|
|
|
func loadCurrentGameOptionKeymap(
|
|
ctx context.Context,
|
|
repository dnfrepo.SettingsRepository,
|
|
accountID string,
|
|
) ([]byte, bool, error) {
|
|
state, _, err := loadCurrentGameOptionState(ctx, repository, accountID)
|
|
return state.keymap, len(state.keymap) != 0, err
|
|
}
|
|
|
|
func saveCurrentGameOptionValue(
|
|
ctx context.Context,
|
|
repository dnfrepo.SettingsRepository,
|
|
accountID string,
|
|
key string,
|
|
body []byte,
|
|
) error {
|
|
if repository == nil || strings.TrimSpace(accountID) == "" || strings.TrimSpace(key) == "" {
|
|
return fmt.Errorf("current game option is invalid")
|
|
}
|
|
return saveCurrentGameOptionScopedValue(ctx, repository, currentGameOptionScope(accountID), key, body)
|
|
}
|
|
|
|
func saveCurrentGameOptionScopedValue(
|
|
ctx context.Context,
|
|
repository dnfrepo.SettingsRepository,
|
|
scope string,
|
|
key string,
|
|
body []byte,
|
|
) error {
|
|
if repository == nil || strings.TrimSpace(scope) == "" || strings.TrimSpace(key) == "" {
|
|
return fmt.Errorf("current game option scope is invalid")
|
|
}
|
|
record, found, err := repository.Load(ctx, scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
record = dnfrepo.SettingsRecord{Scope: scope}
|
|
} else {
|
|
record = dnfrepo.CloneSettings(record)
|
|
}
|
|
if record.Values == nil {
|
|
record.Values = make(map[string]string)
|
|
}
|
|
record.Values[key] = base64.StdEncoding.EncodeToString(body)
|
|
record.UpdatedAt = time.Now().UTC()
|
|
return repository.Save(ctx, record)
|
|
}
|
|
|
|
func saveCurrentGameOption(
|
|
ctx context.Context,
|
|
repository dnfrepo.SettingsRepository,
|
|
accountID string,
|
|
body []byte,
|
|
) error {
|
|
if repository == nil || strings.TrimSpace(accountID) == "" || !validCurrentGameOptionMainBody(body) {
|
|
return fmt.Errorf("current game option is invalid")
|
|
}
|
|
return saveCurrentGameOptionValue(ctx, repository, accountID, currentGameOptionValueKey, body)
|
|
}
|
|
|
|
func saveCurrentGameOptionKeymap(
|
|
ctx context.Context,
|
|
repository dnfrepo.SettingsRepository,
|
|
accountID string,
|
|
body []byte,
|
|
) error {
|
|
if repository == nil || strings.TrimSpace(accountID) == "" || !validCurrentGameOptionKeymapBody(body) {
|
|
return fmt.Errorf("current game option keymap is invalid")
|
|
}
|
|
return saveCurrentGameOptionValue(ctx, repository, accountID, currentGameOptionKeymapValueKey, body)
|
|
}
|
|
|
|
func validCurrentGameOptionQuickChatBody(body []byte) bool {
|
|
return len(body) == currentGameOptionQuickChatBodySize
|
|
}
|
|
|
|
func validCurrentGameOptionQuickChatSaveBody(body []byte) bool {
|
|
return len(body) == currentGameOptionQuickChatSaveBodySize &&
|
|
body[0] < 2 &&
|
|
binary.LittleEndian.Uint32(body[1:1+currentGameOptionSaveHeaderLen]) == currentGameOptionQuickChatBodySize
|
|
}
|
|
|
|
func currentGameOptionQuickChatValueKey(slot byte) string {
|
|
if slot == 0 {
|
|
return currentGameOptionQuickChat0ValueKey
|
|
}
|
|
return currentGameOptionQuickChat1ValueKey
|
|
}
|
|
|
|
func saveCurrentGameOptionQuickChat(
|
|
ctx context.Context,
|
|
repository dnfrepo.SettingsRepository,
|
|
accountID string,
|
|
slot byte,
|
|
body []byte,
|
|
) error {
|
|
if repository == nil || strings.TrimSpace(accountID) == "" || slot >= 2 || !validCurrentGameOptionQuickChatBody(body) {
|
|
return fmt.Errorf("current game option quick chat is invalid")
|
|
}
|
|
return saveCurrentGameOptionValue(ctx, repository, accountID, currentGameOptionQuickChatValueKey(slot), body)
|
|
}
|
|
|
|
// reconcileCurrentGameOptionVisibilityForSelectedCharacter makes the
|
|
// account-scoped visibility options authoritative before USERINFO creates a
|
|
// newly selected actor. Without this step, another character can retain its
|
|
// old/default user_state_bits even though op173 restores the checkbox itself.
|
|
func (s *Service) reconcileCurrentGameOptionVisibilityForSelectedCharacter(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
character dnfrepo.CharacterRecord,
|
|
) (dnfrepo.CharacterRecord, bool, error) {
|
|
if s == nil || session == nil || strings.TrimSpace(character.CharacterID) == "" {
|
|
return character, false, nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Settings == nil || repositories.Character == nil {
|
|
return character, false, nil
|
|
}
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if accountID == "" {
|
|
return character, false, nil
|
|
}
|
|
if owner := strings.TrimSpace(character.AccountID); owner != "" && owner != accountID {
|
|
return character, false, fmt.Errorf("selected character %s owner mismatch", character.CharacterID)
|
|
}
|
|
main, found, err := loadCurrentGameOption(ctx, repositories.Settings, accountID)
|
|
if err != nil || !found {
|
|
return character, false, err
|
|
}
|
|
advanced, valid := currentGameOptionJobAwakenEffectEnabled(main)
|
|
if !valid {
|
|
return character, false, fmt.Errorf("stored current game option job/awakening effect is invalid")
|
|
}
|
|
showAllAvatar, valid := currentGameOptionShowAllAvatarEnabled(main)
|
|
if !valid {
|
|
return character, false, fmt.Errorf("stored current game option avatar visibility is invalid")
|
|
}
|
|
|
|
oldUserStateBits := currentActorDefaultUserStateBits
|
|
if value, exists := character.Stats[currentGameOptionUserStateStat]; exists {
|
|
if value < 0 || value > 0xff {
|
|
return character, false, fmt.Errorf("selected character user state bits %d are outside u8", value)
|
|
}
|
|
oldUserStateBits = byte(value)
|
|
}
|
|
newUserStateBits := currentGameOptionVisibilityUserState(oldUserStateBits, advanced, showAllAvatar)
|
|
if newUserStateBits == oldUserStateBits {
|
|
return character, false, nil
|
|
}
|
|
|
|
character = dnfrepo.CloneCharacter(character)
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
character.Stats[currentGameOptionUserStateStat] = int64(newUserStateBits)
|
|
character.UpdatedAt = time.Now().UTC()
|
|
if err := dnfrepo.SaveCharacterFields(
|
|
ctx,
|
|
repositories.Character,
|
|
character,
|
|
dnfrepo.CharacterFieldStats,
|
|
); err != nil {
|
|
return character, false, fmt.Errorf("save selected character visibility from account game option: %w", err)
|
|
}
|
|
s.logGameEvent(session, "game-game-option-selected-character-visibility-reconciled",
|
|
"account_id", accountID,
|
|
"char_id", character.CharacterID,
|
|
"job_awaken_effect_enabled", advanced,
|
|
"show_all_avatar_enabled", showAllAvatar,
|
|
"old_user_state_bits", oldUserStateBits,
|
|
"user_state_bits", newUserStateBits,
|
|
"source", "account_game_option_before_selected_actor_userinfo")
|
|
return character, true, nil
|
|
}
|
|
|
|
// buildCurrentGameOptionTransportBody follows HandleRequestGameOptionSnapshot
|
|
// in the current EXE: option1[178], quick-chat slot0[300], slot1[300]. The
|
|
// native reader memcpy's each quick-chat vector without a destination-capacity
|
|
// check, so both vectors must always be exactly 300 bytes.
|
|
func buildCurrentGameOptionTransportBody(main, quickChat0, quickChat1 []byte) ([]byte, error) {
|
|
if !validCurrentGameOptionMainBody(main) {
|
|
return nil, fmt.Errorf("current game option main body is invalid")
|
|
}
|
|
quickChats := [2][]byte{quickChat0, quickChat1}
|
|
for index := range quickChats {
|
|
if len(quickChats[index]) == 0 {
|
|
quickChats[index] = make([]byte, currentGameOptionQuickChatBodySize)
|
|
}
|
|
if !validCurrentGameOptionQuickChatBody(quickChats[index]) {
|
|
return nil, fmt.Errorf("current game option quick chat slot%d body is invalid", index)
|
|
}
|
|
}
|
|
body := make([]byte, 0, 3*currentGameOptionSaveHeaderLen+currentGameOptionMainBodySize+2*currentGameOptionQuickChatBodySize)
|
|
body = appendCurrentGameOptionVector(body, main)
|
|
body = appendCurrentGameOptionVector(body, quickChats[0])
|
|
body = appendCurrentGameOptionVector(body, quickChats[1])
|
|
return body, nil
|
|
}
|
|
|
|
// buildCurrentGameOptionKeymapTransportBody follows the current EXE's class0
|
|
// op454 reader: u8 mode followed by the same DSTR emitted by C2S op198.
|
|
func buildCurrentGameOptionKeymapTransportBody(keymap []byte) ([]byte, error) {
|
|
if !validCurrentGameOptionKeymapBody(keymap) {
|
|
return nil, fmt.Errorf("current game option keymap body is invalid")
|
|
}
|
|
body := make([]byte, 1, 1+currentGameOptionSaveHeaderLen+len(keymap))
|
|
body[0] = currentGameOptionKeymapMode
|
|
return appendCurrentGameOptionVector(body, keymap), nil
|
|
}
|
|
|
|
// currentGameOptionLoginRapidFireKeymap extracts only the ordinary-attack
|
|
// binding used by the account-level rapid-fire runtime from textual op454.
|
|
// The on/off switch itself is type197 option 50, not this I01 row.
|
|
//
|
|
// Keeping the early projection to I01 also avoids resetting character-specific
|
|
// Creator and Dark Knight bindings before a character has been selected.
|
|
func currentGameOptionLoginRapidFireKeymap(keymap []byte) ([]byte, bool) {
|
|
for _, rawLine := range bytes.Split(keymap, []byte{'\n'}) {
|
|
fields := strings.Fields(strings.TrimSpace(string(rawLine)))
|
|
if len(fields) != 2 || fields[0] != currentGameOptionRapidFireKeymapID {
|
|
continue
|
|
}
|
|
value, err := strconv.ParseUint(fields[1], 10, 16)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
return []byte(fmt.Sprintf("%s\t%d\n", currentGameOptionRapidFireKeymapID, value)), true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// sendCurrentGameOptionLoginOptions restores the current EXE's account-level
|
|
// fixed option table at the proven post-login/pre-userinfo boundary. Restoring
|
|
// only when rapid fire was enabled left every other option until the later
|
|
// selected-scene layout pass, after process-level input/HUD switches such as
|
|
// skill-slot locking had already initialized from defaults.
|
|
//
|
|
// When rapid fire is enabled, the switch-bearing op173 must still precede the
|
|
// narrow I01 op454 projection: otherwise I01 is applied while option50 is at
|
|
// its process default. Class-0 op455 is intentionally not used: in this client
|
|
// it owns dual-raid state.
|
|
func (s *Service) sendCurrentGameOptionLoginOptions(session *gameSession) error {
|
|
if s == nil || session == nil {
|
|
return nil
|
|
}
|
|
accountID := strings.TrimSpace(session.accountID)
|
|
if accountID == "" {
|
|
return nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Settings == nil {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
state, _, err := loadCurrentGameOptionState(ctx, repositories.Settings, accountID)
|
|
if err != nil {
|
|
return fmt.Errorf("load login game option: %w", err)
|
|
}
|
|
if !validCurrentGameOptionMainBody(state.main) {
|
|
return nil
|
|
}
|
|
mainTransport, err := buildCurrentGameOptionTransportBody(
|
|
state.main,
|
|
state.quickChat[0],
|
|
state.quickChat[1],
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
enabled, _ := currentGameOptionRapidFireEnabled(state.main)
|
|
s.logPacketEvent("game-upper-current-game-option-login-main-send",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"account_id", accountID,
|
|
"msg_id", currentGameOptionMsgID,
|
|
"classification", 0,
|
|
"body_len", len(mainTransport),
|
|
"main_body_len", len(state.main),
|
|
"rapid_fire_option_index", currentGameOptionRapidFireIndex,
|
|
"rapid_fire_enabled", enabled,
|
|
"body_codec", "plaintext",
|
|
"state_source", "account_scoped_type197_after_login_success_before_userinfo")
|
|
if err := s.sendGameUpperRawClass(session, currentGameOptionMsgID, mainTransport, 0); err != nil {
|
|
return err
|
|
}
|
|
if !enabled {
|
|
return nil
|
|
}
|
|
rapidFireKeymap, found := currentGameOptionLoginRapidFireKeymap(state.keymap)
|
|
if !found {
|
|
return nil
|
|
}
|
|
keymapTransport, err := buildCurrentGameOptionKeymapTransportBody(rapidFireKeymap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.logPacketEvent("game-upper-current-game-option-login-rapid-fire-send",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"account_id", accountID,
|
|
"msg_id", currentGameOptionKeymapMsgID,
|
|
"classification", 0,
|
|
"body_len", len(keymapTransport),
|
|
"keymap_body_len", len(rapidFireKeymap),
|
|
"keymap_mode", currentGameOptionKeymapMode,
|
|
"rapid_fire_option_index", currentGameOptionRapidFireIndex,
|
|
"rapid_fire_enabled", enabled,
|
|
"body_codec", "plaintext",
|
|
"state_source", "account_scoped_i01_after_type197_option50_before_userinfo")
|
|
return s.sendGameUpperRawClass(session, currentGameOptionKeymapMsgID, keymapTransport, 0)
|
|
}
|
|
|
|
func appendCurrentGameOptionVector(body, vector []byte) []byte {
|
|
var length [currentGameOptionSaveHeaderLen]byte
|
|
binary.LittleEndian.PutUint32(length[:], uint32(len(vector)))
|
|
body = append(body, length[:]...)
|
|
return append(body, vector...)
|
|
}
|
|
|
|
func clearCurrentGameOptionRestoreSnapshot(session *gameSession) {
|
|
if session == nil {
|
|
return
|
|
}
|
|
session.initialTownGameOptionRestoreCharacterID = 0
|
|
clearCurrentActorUserStateRestoreSnapshot(session)
|
|
session.currentGameOptionMu.Lock()
|
|
session.currentGameOptionMainRestoreCharacterID = 0
|
|
session.currentGameOptionNativeApplyBody = nil
|
|
session.currentGameOptionNativeApplyKeymapBody = nil
|
|
session.currentGameOptionNativeApplyMainAt = time.Time{}
|
|
session.currentGameOptionNativeApplyKeymapAt = time.Time{}
|
|
session.currentGameOptionMu.Unlock()
|
|
}
|
|
|
|
// sendCurrentGameOptionMainBeforeSceneLayoutOnce restores the account-scoped
|
|
// fixed option table at the current EXE's first safe layout boundary. Current
|
|
// client packet-order regressions require the following class-0 op124 before
|
|
// UI-size-dependent state becomes visible. Callers must therefore bind the
|
|
// selected actor's mode1 descriptor
|
|
// first, then use sendCurrentSceneLayoutCommit instead of writing op124
|
|
// directly. The character-aware op454 keymap remains owned by the later
|
|
// HUD-ready boundary.
|
|
func (s *Service) sendCurrentGameOptionMainBeforeSceneLayoutOnce(session *gameSession, source string) error {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return nil
|
|
}
|
|
characterID := session.selectedCharacterID
|
|
session.currentGameOptionMu.Lock()
|
|
alreadySent := session.currentGameOptionMainRestoreCharacterID == characterID
|
|
session.currentGameOptionMu.Unlock()
|
|
if alreadySent {
|
|
return nil
|
|
}
|
|
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Settings == nil {
|
|
return nil
|
|
}
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
state, _, err := loadCurrentGameOptionState(ctx, repositories.Settings, accountID)
|
|
if err != nil {
|
|
return fmt.Errorf("load current game option before scene layout: %w", err)
|
|
}
|
|
var transport []byte
|
|
if len(state.main) != 0 {
|
|
transport, err = buildCurrentGameOptionTransportBody(state.main, state.quickChat[0], state.quickChat[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Serialize the final identity check and write so parallel scene callbacks
|
|
// cannot publish the same op173 twice. A failed write leaves the cursor open
|
|
// and clears only the uncommitted native-echo snapshot for an exact retry.
|
|
session.currentGameOptionMu.Lock()
|
|
defer session.currentGameOptionMu.Unlock()
|
|
if session.selectedCharacterID != characterID ||
|
|
session.currentGameOptionMainRestoreCharacterID == characterID {
|
|
return nil
|
|
}
|
|
if len(transport) == 0 {
|
|
session.currentGameOptionMainRestoreCharacterID = characterID
|
|
return nil
|
|
}
|
|
now := time.Now()
|
|
session.currentGameOptionNativeApplyBody = append([]byte(nil), state.main...)
|
|
session.currentGameOptionNativeApplyMainAt = now
|
|
s.logPacketEvent("game-upper-current-game-option-main-before-scene-layout-send",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"account_id", accountID,
|
|
"char_id", characterID,
|
|
"msg_id", currentGameOptionMsgID,
|
|
"classification", 0,
|
|
"body_len", len(transport),
|
|
"main_body_len", len(state.main),
|
|
"quickchat0_body_len", currentGameOptionQuickChatBodySize,
|
|
"quickchat1_body_len", currentGameOptionQuickChatBodySize,
|
|
"body_codec", "plaintext",
|
|
"state_source", "account_scoped_current_exe_type197_main_before_first_op124_ui_layout")
|
|
if err := s.sendGameUpperRawClass(session, currentGameOptionMsgID, transport, 0); err != nil {
|
|
session.currentGameOptionNativeApplyBody = nil
|
|
session.currentGameOptionNativeApplyMainAt = time.Time{}
|
|
return err
|
|
}
|
|
session.currentGameOptionMainRestoreCharacterID = characterID
|
|
return nil
|
|
}
|
|
|
|
// sendCurrentSceneLayoutCommit keeps the current EXE's exact dependency:
|
|
// actor mode1 descriptor -> account option table -> op124 layout calculation.
|
|
func (s *Service) sendCurrentSceneLayoutCommit(session *gameSession, source string) error {
|
|
if err := s.sendCurrentGameOptionMainBeforeSceneLayoutOnce(session, source+"_before_op124"); err != nil {
|
|
return err
|
|
}
|
|
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketReportClientSpec), nil, 0)
|
|
}
|
|
|
|
// sendCurrentGameOptionAfterLegacySceneReadyOnce restores the character-aware
|
|
// keymap after the current EXE has finished its HUD rebuild. The fixed main
|
|
// table normally arrived before the first op124 through
|
|
// sendCurrentSceneLayoutCommit; a main-table fallback is retained only for a
|
|
// route that reaches this boundary without that proven layout commit.
|
|
// This deliberately precedes the per-character op391 restore in the same
|
|
// type1345 boundary, matching the independent native option readers.
|
|
func (s *Service) sendCurrentGameOptionAfterLegacySceneReadyOnce(session *gameSession, source string) error {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return nil
|
|
}
|
|
session.townMu.Lock()
|
|
characterID := session.selectedCharacterID
|
|
ready := session.initialTownLegacySceneReadyAccepted &&
|
|
session.initialTownRouteCharacterID == characterID &&
|
|
session.initialTownRouteStage >= currentInitialTownRoutePlayerStateSent &&
|
|
!session.sceneBootstrapTailDeferred &&
|
|
session.sceneBootstrapTailSent &&
|
|
session.initialTownGameOptionRestoreCharacterID != characterID
|
|
session.townMu.Unlock()
|
|
if !ready {
|
|
return nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Settings == nil {
|
|
return nil
|
|
}
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
state, found, err := loadCurrentGameOptionState(ctx, repositories.Settings, accountID)
|
|
if err != nil {
|
|
return fmt.Errorf("load current game option: %w", err)
|
|
}
|
|
var keymapStateSource string
|
|
state.keymap, keymapStateSource, err = s.currentGameOptionKeymapForSelectedCharacter(
|
|
ctx,
|
|
repositories,
|
|
session,
|
|
state.keymap,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("load selected character keymap: %w", err)
|
|
}
|
|
found = found || len(state.keymap) != 0
|
|
var mainTransport []byte
|
|
var keymapTransport []byte
|
|
session.currentGameOptionMu.Lock()
|
|
mainRestoredBeforeLayout := session.currentGameOptionMainRestoreCharacterID == characterID
|
|
session.currentGameOptionMu.Unlock()
|
|
if found {
|
|
if len(state.main) != 0 && !mainRestoredBeforeLayout {
|
|
mainTransport, err = buildCurrentGameOptionTransportBody(state.main, state.quickChat[0], state.quickChat[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(state.keymap) != 0 {
|
|
keymapTransport, err = buildCurrentGameOptionKeymapTransportBody(state.keymap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
session.townMu.Lock()
|
|
defer session.townMu.Unlock()
|
|
if !session.initialTownLegacySceneReadyAccepted ||
|
|
session.selectedCharacterID != characterID ||
|
|
session.initialTownRouteCharacterID != characterID ||
|
|
session.initialTownRouteStage < currentInitialTownRoutePlayerStateSent ||
|
|
session.sceneBootstrapTailDeferred ||
|
|
!session.sceneBootstrapTailSent ||
|
|
session.initialTownGameOptionRestoreCharacterID == characterID {
|
|
return nil
|
|
}
|
|
if !found {
|
|
session.initialTownGameOptionRestoreCharacterID = characterID
|
|
return nil
|
|
}
|
|
session.currentGameOptionMu.Lock()
|
|
defer session.currentGameOptionMu.Unlock()
|
|
if session.currentGameOptionMainRestoreCharacterID == characterID {
|
|
mainTransport = nil
|
|
}
|
|
now := time.Now()
|
|
if len(mainTransport) != 0 {
|
|
session.currentGameOptionNativeApplyBody = append([]byte(nil), state.main...)
|
|
session.currentGameOptionNativeApplyMainAt = now
|
|
}
|
|
if len(keymapTransport) != 0 {
|
|
session.currentGameOptionNativeApplyKeymapBody = append([]byte(nil), state.keymap...)
|
|
session.currentGameOptionNativeApplyKeymapAt = now
|
|
}
|
|
if len(mainTransport) != 0 {
|
|
s.logPacketEvent("game-upper-current-game-option-restore-send",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"account_id", accountID,
|
|
"char_id", characterID,
|
|
"msg_id", currentGameOptionMsgID,
|
|
"classification", 0,
|
|
"body_len", len(mainTransport),
|
|
"main_body_len", len(state.main),
|
|
"quickchat0_body_len", currentGameOptionQuickChatBodySize,
|
|
"quickchat1_body_len", currentGameOptionQuickChatBodySize,
|
|
"body_codec", "plaintext",
|
|
"state_source", "account_scoped_current_exe_type197_main_and_type368_quickchat_after_type1345_hud_ready")
|
|
if err := s.sendGameUpperRawClass(session, currentGameOptionMsgID, mainTransport, 0); err != nil {
|
|
session.currentGameOptionNativeApplyBody = nil
|
|
session.currentGameOptionNativeApplyKeymapBody = nil
|
|
session.currentGameOptionNativeApplyMainAt = time.Time{}
|
|
session.currentGameOptionNativeApplyKeymapAt = time.Time{}
|
|
return err
|
|
}
|
|
session.currentGameOptionMainRestoreCharacterID = characterID
|
|
}
|
|
if len(keymapTransport) != 0 {
|
|
s.logPacketEvent("game-upper-current-game-option-keymap-restore-send",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"account_id", accountID,
|
|
"char_id", characterID,
|
|
"msg_id", currentGameOptionKeymapMsgID,
|
|
"classification", 0,
|
|
"body_len", len(keymapTransport),
|
|
"keymap_body_len", len(state.keymap),
|
|
"keymap_mode", currentGameOptionKeymapMode,
|
|
"body_codec", "plaintext",
|
|
"state_source", keymapStateSource)
|
|
if err := s.sendGameUpperRawClass(session, currentGameOptionKeymapMsgID, keymapTransport, 0); err != nil {
|
|
session.currentGameOptionNativeApplyBody = nil
|
|
session.currentGameOptionNativeApplyKeymapBody = nil
|
|
session.currentGameOptionNativeApplyMainAt = time.Time{}
|
|
session.currentGameOptionNativeApplyKeymapAt = time.Time{}
|
|
return err
|
|
}
|
|
}
|
|
session.initialTownGameOptionRestoreCharacterID = characterID
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentSaveGameOption(session *gameSession, body []byte) error {
|
|
if s == nil || session == nil || !validCurrentGameOptionSaveBody(body) {
|
|
if s != nil {
|
|
s.logGameEvent(session, "game-game-option-save-deferred",
|
|
"body_len", len(body),
|
|
"reason", "current_exe_type197_body_invalid")
|
|
}
|
|
return nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Settings == nil || repositories.Character == nil {
|
|
s.logGameEvent(session, "game-game-option-save-deferred",
|
|
"body_len", len(body),
|
|
"reason", "settings_or_character_repository_unavailable")
|
|
return nil
|
|
}
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
main := append([]byte(nil), body[currentGameOptionSaveHeaderLen:]...)
|
|
if currentGameOptionIsNativeApplyEcho(session, main) {
|
|
s.logGameEvent(session, "game-game-option-native-apply-echo-ignored",
|
|
"account_id", accountID,
|
|
"body_len", len(main),
|
|
"reason", "current_exe_op173_apply_reemits_identical_type197")
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
enabled, valid := currentGameOptionJobAwakenEffectEnabled(main)
|
|
if !valid {
|
|
return fmt.Errorf("current game option job/awakening effect is invalid")
|
|
}
|
|
showAllAvatar, valid := currentGameOptionShowAllAvatarEnabled(main)
|
|
if !valid {
|
|
return fmt.Errorf("current game option avatar visibility is invalid")
|
|
}
|
|
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
|
visibilityChanged := false
|
|
newUserStateBits := currentActorDefaultUserStateBits
|
|
if session.selectedCharacterID == 0 {
|
|
if err := saveCurrentGameOption(ctx, repositories.Settings, accountID, main); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if repositories.CeraShopAssets == nil {
|
|
return fmt.Errorf("save current game option and character visibility: %w", dnfrepo.ErrCeraShopAssetTransactionUnavailable)
|
|
}
|
|
if err := repositories.CeraShopAssets.WithinCeraShopAssets(
|
|
ctx,
|
|
accountID,
|
|
characterID,
|
|
currentGameOptionScope(accountID),
|
|
func(
|
|
_ dnfrepo.AccountRepository,
|
|
_ dnfrepo.AccountInventoryRepository,
|
|
characters dnfrepo.CharacterRepository,
|
|
_ dnfrepo.InventoryRepository,
|
|
_ dnfrepo.EquipmentRepository,
|
|
settings dnfrepo.SettingsRepository,
|
|
) error {
|
|
character, found, err := characters.Load(ctx, characterID)
|
|
if err != nil {
|
|
return fmt.Errorf("load selected character before game option save: %w", err)
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("selected character %s was not found", characterID)
|
|
}
|
|
if accountID != "" && strings.TrimSpace(character.AccountID) != accountID {
|
|
return fmt.Errorf("selected character %s owner mismatch", characterID)
|
|
}
|
|
character = dnfrepo.CloneCharacter(character)
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
oldUserStateBits := currentActorDefaultUserStateBits
|
|
if value, exists := character.Stats[currentGameOptionUserStateStat]; exists {
|
|
if value < 0 || value > 0xff {
|
|
return fmt.Errorf("selected character user state bits %d are outside u8", value)
|
|
}
|
|
oldUserStateBits = byte(value)
|
|
}
|
|
newUserStateBits = currentGameOptionVisibilityUserState(oldUserStateBits, enabled, showAllAvatar)
|
|
visibilityChanged = oldUserStateBits != newUserStateBits
|
|
if visibilityChanged {
|
|
character.Stats[currentGameOptionUserStateStat] = int64(newUserStateBits)
|
|
character.UpdatedAt = time.Now().UTC()
|
|
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
|
return fmt.Errorf("save selected character visibility: %w", err)
|
|
}
|
|
}
|
|
if err := saveCurrentGameOption(ctx, settings, accountID, main); err != nil {
|
|
return fmt.Errorf("save account game option: %w", err)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.logGameEvent(session, "game-game-option-saved",
|
|
"account_id", accountID,
|
|
"char_id", session.selectedCharacterID,
|
|
"body_len", len(main),
|
|
"job_awaken_effect_enabled", enabled,
|
|
"show_all_avatar_enabled", showAllAvatar,
|
|
"user_state_bits", newUserStateBits,
|
|
"storage", "account_settings_and_character_visibility")
|
|
if visibilityChanged && !currentDungeonSceneActive(session) && currentTownSelfActorAppearanceReady(session) {
|
|
if err := s.sendSelectedActorUserStateRefresh(session, "save_game_option_avatar_visibility_options1_55"); err != nil {
|
|
s.logWarn("dnfbridge deferred avatar visibility user-state refresh",
|
|
"conn_id", session.connID,
|
|
"char_id", session.selectedCharacterID,
|
|
"error", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentSaveGameOptionKeymap(session *gameSession, body []byte) error {
|
|
if s == nil || session == nil || !validCurrentGameOptionKeymapSaveBody(body) {
|
|
if s != nil {
|
|
s.logGameEvent(session, "game-game-option-keymap-save-deferred",
|
|
"body_len", len(body),
|
|
"reason", "current_exe_type198_body_invalid")
|
|
}
|
|
return nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Settings == nil {
|
|
s.logGameEvent(session, "game-game-option-keymap-save-deferred",
|
|
"body_len", len(body),
|
|
"reason", "settings_repository_unavailable")
|
|
return nil
|
|
}
|
|
keymap := append([]byte(nil), body[currentGameOptionSaveHeaderLen:]...)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if currentGameOptionKeymapIsNativeApplyEcho(session, keymap) {
|
|
s.logGameEvent(session, "game-game-option-keymap-native-apply-echo-ignored",
|
|
"account_id", accountID,
|
|
"body_len", len(keymap),
|
|
"reason", "current_exe_class0_op454_apply_reemits_identical_type198")
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
// 缔造者把键位表交回职业默认(客户端点"恢复默认"后保存,或教学期回显):客户端那张
|
|
// 表不带缔造者的输入模型(I01..I05 是普通角色的 2/0/3/1/78)。这条不是"玩家改键",
|
|
// 而是"回到职业默认":既要丢掉角色作用域里存的绑定,又必须把 PVF 的缔造者表推回
|
|
// 客户端 —— 客户端本地已经换成普通默认表了,只拒绝保存界面还是错的。
|
|
if s.currentGameOptionKeymapSaveIsCreatorDefaultRequest(ctx, repositories, session, keymap, accountID) {
|
|
character, job, found, err := s.selectedCharacterJobForKeymap(ctx, repositories, session)
|
|
if err != nil || !found || !currentSpecialJobOwnsCharacterKeymap(job) {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-game-option-keymap-creator-restore-default",
|
|
"account_id", accountID,
|
|
"char_id", session.selectedCharacterID,
|
|
"body_len", len(keymap),
|
|
"reason", "saved_keymap_does_not_carry_the_creator_input_model",
|
|
"action", "drop_stored_binding_and_push_the_pvf_creator_table",
|
|
"state_source", "pvf_creator_ver2_wasd_space_digit_skill_keymap_locked")
|
|
return s.restoreCurrentCreatorKeymapDefault(ctx, repositories, session, character)
|
|
}
|
|
scope, storage, err := s.currentGameOptionKeymapSaveScope(ctx, repositories, session, accountID)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve current game option keymap scope: %w", err)
|
|
}
|
|
if err := saveCurrentGameOptionKeymapToScope(ctx, repositories.Settings, scope, keymap); err != nil {
|
|
return fmt.Errorf("save current game option keymap: %w", err)
|
|
}
|
|
s.logGameEvent(session, "game-game-option-keymap-saved",
|
|
"account_id", accountID,
|
|
"char_id", session.selectedCharacterID,
|
|
"body_len", len(keymap),
|
|
"storage", storage,
|
|
"scope", scope,
|
|
"protocol_evidence", "current_exe_type198_u32_length_prefixed_keymap")
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentSaveGameOptionQuickChat(session *gameSession, body []byte) error {
|
|
if s == nil || session == nil || !validCurrentGameOptionQuickChatSaveBody(body) {
|
|
if s != nil {
|
|
s.logGameEvent(session, "game-game-option-quickchat-save-deferred",
|
|
"body_len", len(body),
|
|
"reason", "current_exe_type368_body_invalid")
|
|
}
|
|
return nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Settings == nil {
|
|
s.logGameEvent(session, "game-game-option-quickchat-save-deferred",
|
|
"body_len", len(body),
|
|
"reason", "settings_repository_unavailable")
|
|
return nil
|
|
}
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
slot := body[0]
|
|
quickChat := append([]byte(nil), body[1+currentGameOptionSaveHeaderLen:]...)
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
if err := saveCurrentGameOptionQuickChat(ctx, repositories.Settings, accountID, slot, quickChat); err != nil {
|
|
return fmt.Errorf("save current game option quick chat slot%d: %w", slot, err)
|
|
}
|
|
s.logGameEvent(session, "game-game-option-quickchat-saved",
|
|
"account_id", accountID,
|
|
"char_id", session.selectedCharacterID,
|
|
"slot", slot,
|
|
"body_len", len(quickChat),
|
|
"storage", "account_settings",
|
|
"protocol_evidence", "current_exe_type368_u8_slot_plus_u32_300_plus_raw300")
|
|
return nil
|
|
}
|
|
|
|
// sendCurrentGameOptionImmediately makes a saved settings panel take effect in
|
|
// the current town. The client owns the individual option semantics, including
|
|
// whether the selected character's job and grow type qualify for an effect.
|
|
func (s *Service) sendCurrentGameOptionImmediately(session *gameSession, main, keymap []byte) error {
|
|
if s == nil || session == nil || session.conn == nil {
|
|
return nil
|
|
}
|
|
transport, err := buildCurrentGameOptionTransportBody(main, nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.logPacketEvent("game-upper-current-game-option-immediate-apply-send",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", "legacy_type197_type198_save",
|
|
"char_id", session.selectedCharacterID,
|
|
"msg_id", currentGameOptionMsgID,
|
|
"classification", 0,
|
|
"body_len", len(transport),
|
|
"body_codec", "plaintext",
|
|
"state_source", "current_exe_type197_main_and_fixed_empty_quickchat_immediate_native_apply")
|
|
rememberCurrentGameOptionNativeApply(session, main, keymap)
|
|
if err := s.sendGameUpperRawClass(session, currentGameOptionMsgID, transport, 0); err != nil {
|
|
clearCurrentGameOptionNativeApply(session)
|
|
return err
|
|
}
|
|
if len(keymap) != 0 {
|
|
keymapTransport, err := buildCurrentGameOptionKeymapTransportBody(keymap)
|
|
if err != nil {
|
|
clearCurrentGameOptionNativeApply(session)
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperRawClass(session, currentGameOptionKeymapMsgID, keymapTransport, 0); err != nil {
|
|
clearCurrentGameOptionNativeApply(session)
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rememberCurrentGameOptionNativeApply(session *gameSession, main, keymap []byte) {
|
|
if session == nil {
|
|
return
|
|
}
|
|
session.currentGameOptionMu.Lock()
|
|
now := time.Now()
|
|
session.currentGameOptionNativeApplyBody = append([]byte(nil), main...)
|
|
session.currentGameOptionNativeApplyKeymapBody = append([]byte(nil), keymap...)
|
|
session.currentGameOptionNativeApplyMainAt = now
|
|
session.currentGameOptionNativeApplyKeymapAt = now
|
|
session.currentGameOptionMu.Unlock()
|
|
}
|
|
|
|
func clearCurrentGameOptionNativeApply(session *gameSession) {
|
|
if session == nil {
|
|
return
|
|
}
|
|
session.currentGameOptionMu.Lock()
|
|
session.currentGameOptionNativeApplyBody = nil
|
|
session.currentGameOptionNativeApplyKeymapBody = nil
|
|
session.currentGameOptionNativeApplyMainAt = time.Time{}
|
|
session.currentGameOptionNativeApplyKeymapAt = time.Time{}
|
|
session.currentGameOptionMu.Unlock()
|
|
}
|
|
|
|
func currentGameOptionIsNativeApplyEcho(session *gameSession, main []byte) bool {
|
|
if session == nil || !validCurrentGameOptionMainBody(main) {
|
|
return false
|
|
}
|
|
session.currentGameOptionMu.Lock()
|
|
defer session.currentGameOptionMu.Unlock()
|
|
if session.currentGameOptionNativeApplyMainAt.IsZero() ||
|
|
time.Since(session.currentGameOptionNativeApplyMainAt) > currentGameOptionNativeApplyGracePeriod {
|
|
return false
|
|
}
|
|
return bytes.Equal(session.currentGameOptionNativeApplyBody, main)
|
|
}
|
|
|
|
func currentGameOptionKeymapIsNativeApplyEcho(session *gameSession, keymap []byte) bool {
|
|
if session == nil || !validCurrentGameOptionKeymapBody(keymap) {
|
|
return false
|
|
}
|
|
session.currentGameOptionMu.Lock()
|
|
defer session.currentGameOptionMu.Unlock()
|
|
if session.currentGameOptionNativeApplyKeymapAt.IsZero() ||
|
|
time.Since(session.currentGameOptionNativeApplyKeymapAt) > currentGameOptionNativeApplyGracePeriod {
|
|
return false
|
|
}
|
|
return bytes.Equal(session.currentGameOptionNativeApplyKeymapBody, keymap)
|
|
}
|