Files
90CNServer/server/internal/services/dnfbridge/initial_town_player_state.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

650 lines
26 KiB
Go

package dnfbridge
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// sendCurrentInitialTownPlayerState completes the selected town actor before
// the final op24 transition commit. No DOVE packet body and no synthetic C2S
// op37 is used here.
func (s *Service) sendCurrentInitialTownPlayerState(session *gameSession, source string) error {
if session == nil {
return nil
}
session.townMu.Lock()
defer session.townMu.Unlock()
return s.sendCurrentInitialTownPlayerStateLocked(session, source)
}
// sendCurrentInitialTownPlayerStateLocked sends every fallible town actor
// initialization phase before op24. The caller must hold session.townMu. A
// failure leaves the route below currentInitialTownRouteTransitionSent so the
// client remains on its current page and an exact retry can resume safely.
func (s *Service) sendCurrentInitialTownPlayerStateLocked(session *gameSession, source string) error {
return s.sendCurrentTownPlayerStateLocked(
session,
source,
currentInitialTownRoutePolicy{
includeReportClientSpec: true,
includeSecondFullMode1: true,
},
)
}
// sendCurrentTownPlayerStateLocked applies the selected route policy to the
// shared scene-ready finalizer. The caller must hold session.townMu.
func (s *Service) sendCurrentTownPlayerStateLocked(
session *gameSession,
source string,
policy currentInitialTownRoutePolicy,
) error {
if session == nil {
return nil
}
characterID := session.initialTownRouteCharacterID
stage := session.initialTownRouteStage
if characterID == 0 || characterID != session.selectedCharacterID ||
stage < currentInitialTownRouteActorBound {
return nil
}
if stage == currentInitialTownRoutePlayerStatePrepared ||
stage >= currentInitialTownRoutePlayerStateSent {
s.logGameEvent(session, "game-initial-town-player-state-duplicate-skipped",
"source", source,
"char_id", characterID,
"route_stage", stage)
return nil
}
// Do not arm a selected-actor mode3 refresh here. The current EXE treats
// class0/op2 mode3 as the personal-information panel route, so sending it
// during login opens that panel without user action. Mode1 already installs
// the authoritative actor state and op205 owns the profession projection.
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
charID, charName, character, hasCharacter := s.selectedCharacterForEnter(ctx, session)
if charID != characterID || !hasCharacter {
return fmt.Errorf("initial town player state character mismatch: selected=%d loaded=%d found=%t", characterID, charID, hasCharacter)
}
objectKey := currentSceneActorObjectKey(charID)
sequence := "actor_bound_then_op358_pages_op376_op391_cargo_reset_op13_containers_op251_item_lock_snapshot_op898_crystal_op863_aura_op1340_op19_weapon_validation_preload_full_mode1_op19_final_pet_only_op14_slot26_op105_op102_op21_acceptable_op574_active_op1346_op359_op356_op9_op120_then_typed_op24_without_op19_replay_then_scene_ready_op108_spend_time_op1206_op1303"
if policy.includeReportClientSpec {
sequence = "actor_bound_then_op358_pages_op376_op391_cargo_reset_op13_containers_op251_item_lock_snapshot_op898_crystal_op863_aura_op1340_op19_weapon_validation_preload_full_mode1_op19_final_pet_only_op14_slot26_op105_op102_op21_acceptable_op574_active_op1346_op359_op356_op124_op9_op120_then_typed_op24_without_op19_replay_then_scene_ready_op108_spend_time_op1206_op1303"
}
s.logGameEvent(session, "game-initial-town-player-state-send",
"source", source,
"char_id", charID,
"object_key", objectKey,
"route_stage", stage,
"sequence", sequence,
"report_client_spec_sent", policy.includeReportClientSpec,
"action_table", "selected_before_typed_op24_final_transition_commit",
"transition_policy", "do_not_send_op24_until_full_player_state_succeeds",
"evidence", "user_required_pretransition_initialization_with_current_exe_typed_bodies")
if err := s.sendCurrentSceneOverseerPages(ctx, session); err != nil {
return err
}
// Select the active action table after the five overseer pages and before
// container/player state. The final op24 transition is withheld until every
// phase in this function has succeeded.
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketPVPMissionHpPercent),
buildCurrentActionTableStateBody(),
0,
); err != nil {
return err
}
// Current NoPack resolves the destination container manager before it
// installs class0/op13 rows. The actor-binding stage above must therefore
// own the one-shot bootstrap; sending it next to the select ACK is too early
// and leaves a visibly empty inventory even when the packet contains rows.
// Cargo-pad state remains first so the personal-cargo capacity is valid.
if err := s.sendCurrentCargoPadResetBeforeItemLists(session, source+"_before_full_player_state"); err != nil {
return err
}
if err := s.sendCurrentSelectInventoryBootstrap(session, source+"_after_actor_before_full_player_state"); err != nil {
return err
}
// Current NoPack dispatches op898 only through the class0 notification
// table. Publish the durable crystal selection after all six native
// inventory managers exist, but before op1340/full mode1/op24 can expose
// the town UI. The session gate makes the later client-authored op36 path an
// idempotent fallback instead of a second initialization.
if err := s.sendCurrentCrystalContractStateOnce(
session,
source+"_after_inventory_bootstrap_before_adventure_mode1",
); err != nil {
return err
}
// The durable aura_flag is not sufficient for the current EXE to render the
// aura appearance slot as open. Publish the marked op863 state on cold
// login, return-select, and reconnect after the inventory managers exist.
// This bootstrap is the sole lifecycle owner: ordinary op36 town movement
// must not append state packets or disturb its response ordering.
if err := s.sendCurrentAuraSkinSlotTownUIReadyState(session); err != nil {
return err
}
var legacyRepo dnfrepo.LegacyUserInfoRepository
if repos, ok := s.repositoryGroup(); ok {
legacyRepo = repos.LegacyUserInfo
}
adventureSummary := s.currentAccountAdventureGroupSummaryForPacket(ctx, session, character, hasCharacter)
adventureLevel := uint32(adventureSummary.ManageLevel)
// sub_C7DAF0 (op1340) populates the account-wide adventure model consumed
// by mode1/sub_2008010 before it applies the real management-level option
// rows, including the four basic-stat bonus. The mode0/mode1 prerequisite
// above has already created and bound the selected actor, so op1340's
// current-object check is satisfied before the final transition commit.
if policy.includeSecondFullMode1 {
if err := s.sendCurrentAdventureInfoPushFromAccount(session, objectKey, source+"_before_mode1_adventure_model"); err != nil {
return err
}
}
// Do not populate the expert-job manager before the first op19. This client
// interprets that order as a newly acquired qualification. The durable op205
// state is restored below, after the ordinary scene skill snapshot.
//
// Cross-character selection retains the previous actor's skill manager
// until op19 is consumed. The full equipment-bearing mode1 immediately
// creates and validates worn equipment; sending it first lets
// weapon-mastery jobs (Weapon Master, Vagabond and similar branches) queue a
// client-authored unequip before the correct skills arrive. Build the
// projection once, preload only op19 before that mode1, then replay the
// identical projection at the established final boundary so quickbars and
// tree restoration keep their final-owner lifecycle.
skillProjection, skillProjectionReady, err := s.buildCurrentSceneSkillInfoProjection(
session,
ctx,
character,
source+"_before_full_mode1_weapon_validation",
)
if err != nil {
return err
}
if skillProjectionReady && policy.includeSecondFullMode1 {
if err := s.sendCurrentSceneSkillInfoProjectionBeforeEquipment(
session,
skillProjection,
source+"_before_full_mode1_weapon_validation",
); err != nil {
return err
}
}
mode1Body := s.buildCurrentSelectedUserInfoMode1BodyWithAdventureLevelInContext(
ctx,
session,
legacyRepo,
character,
hasCharacter,
charID,
adventureLevel,
currentTownActorOwnerContext(session),
)
if !policy.includeSecondFullMode1 {
// The route already created the selected actor with the complete,
// equipment-bearing mode1 immediately after mode0. Replaying it here
// would rebuild the same actor slots after the container bootstrap.
s.logPacketEvent("game-initial-town-full-mode1-duplicate-skipped",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", charID,
"object_key", objectKey,
"owner_channel", session.townActorOwnerChannel,
"body_len", len(mode1Body),
"reason", "complete_mode1_was_sent_immediately_after_mode0")
} else {
s.logPacketEvent("game-initial-town-full-mode1-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", charID,
"object_key", objectKey,
"adventure_total_point", adventureSummary.TotalPoint,
"adventure_manage_level", adventureSummary.ManageLevel,
"adventure_manage_option", adventureSummary.ManageOption,
"body_len", len(mode1Body),
"stat_blob_len", currentMode1StatBlobWireSize,
"body_source", "current_exe_sub_2008010_existing_town_actor_real_state_and_equipment_create")
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSetUDPIPPort), mode1Body, 0); err != nil {
return err
}
// Channel reconnect first binds a lightweight mode1 and only reaches the
// complete equipment-bearing owner row here. Cold login deliberately omits
// that prerequisite mode1 to avoid replaying native qualification state, so
// its exact already-written mode0 arrives through the route policy. In both
// cases form the admission cache only after this complete mode1 write.
projection, found := currentPartyActorProjectionSnapshot(
session,
charID,
session.characterGeneration,
)
mode0Body := projection.mode0
if !found {
mode0Body = policy.actorMode0Body
}
if len(mode0Body) > 0 {
if err := captureCurrentPartyActorProjection(
session,
charID,
mode0Body,
mode1Body,
); err != nil {
s.logGameEvent(session, "game-party-actor-full-projection-refresh-rejected",
"source", source,
"char_id", charID,
"character_generation", session.characterGeneration,
"mode0_len", len(mode0Body),
"mode1_len", len(mode1Body),
"reason", err)
} else {
event := "game-party-actor-full-projection-captured"
if found {
event = "game-party-actor-full-projection-refreshed"
}
s.logGameEvent(session, event,
"source", source,
"char_id", charID,
"character_generation", session.characterGeneration,
"owner_server", mode0Body[3],
"owner_channel", mode0Body[4],
"mode0_len", len(mode0Body),
"mode1_len", len(mode1Body))
}
}
}
session.selectedUserInfoRefreshSent = true
// Cold login, return-select and channel reconnect all reach their final
// equipment-bearing mode1 in this function. The collection-box projection
// was published earlier (the select-ACK seed, or the actor prerequisites
// between mode0 and mode1), so refresh the selected box once here, against
// the actor that now owns the native collect-box stat owner.
if err := s.replayCollectboxSelectedRefreshAfterMode1(
ctx,
session,
source+"_after_full_mode1",
); err != nil {
s.logGameEvent(session, "game-collectbox-selection-refresh-deferred",
"char_id", charID,
"source", source+"_after_full_mode1",
"reason", err)
}
// Current NoPack owns the Guardian's five shield choices in an independent
// class0/op580 table. Publish it only after the complete equipment-bearing
// mode1 actor exists and before the first typed town op24 transition. A live
// main shield then uses slot 24's native op342/op357 redraw path; it is not a
// member of mode0's fourteen-row paper-doll table.
if _, err := s.sendSelectedGuardianShieldDeckInfo(
session,
source+"_guardian_shield_deck_after_full_mode1_before_typed_op24",
); err != nil {
return err
}
if _, err := s.sendSelectedGuardianShieldTownAppearanceRefreshOnce(
session,
source+"_guardian_shield_appearance_after_deck_before_typed_op24",
); err != nil {
return err
}
// The current client does not build either emoticon pane from PVF by
// itself. Publish the proved class1/op411 catalog after the selected actor
// exists so both the chat picker and its settings page are populated.
if _, err := s.sendSelectedChatEmoticonCatalog(
session,
source+"_chat_emoticon_catalog_after_full_mode1_before_typed_op24",
); err != nil {
return err
}
// Mode1 creates the worn medal object but does not carry its guardian-gem
// raw socket words. Rehydrate only a medal that actually has a persisted
// guardian gem through the proved list-3 op14 carrier before the first
// visible town transition. This is intentionally narrower than a generic
// equipment refresh, which the current client rejects during initialization.
if err := s.sendSelectedGuardianGemWornMedalRefresh(
session,
source+"_after_full_mode1_before_creature_state",
); err != nil {
return err
}
// The same boundary also owes the worn title its raw random-enchant bytes:
// the create rows do not carry +0x61..+0x65, and nothing else in this build
// ever publishes a worn list-3 row (see random_title_enchant_refresh.go).
if err := s.sendSelectedWornTitleEnchantRowRefresh(
session,
source+"_after_full_mode1_before_creature_state",
); err != nil {
return err
}
// Mode1 creates the weapon object without the durable equipment-effect rune
// word. Hydrate only weapons that actually carry a persisted rune through
// the accepted list-3 op14 carrier.
if err := s.sendSelectedEquippedEquipmentEffectRuneRefresh(
session,
source+"_after_full_mode1_before_creature_state",
); err != nil {
return err
}
// The complete mode1 replaces the current EXE's native skill manager. The
// durable command list must therefore be projected after that actor-binding
// boundary. A second mode0 here would recreate the town actor and make the
// client visibly snap during login/reselect.
// Keeping this before typed op24 preserves the known no-qualification-dialog
// transition, while making cold-login state use the exact same persistence
// source as dungeon entry and town return.
if skillProjectionReady {
// Full mode1 may replace the native skill manager seeded for equipment
// validation. Replay the exact same projection here; this final op19
// remains the sole owner of quickbar and tree restoration.
if err := s.sendInitialTownSkillInfoProjectionBeforeTransition(
session,
skillProjection,
source+"_after_final_mode0_mode1_before_typed_op24",
); err != nil {
return err
}
} else if err := s.sendInitialTownSkillInfoBeforeTransition(
session,
ctx,
character,
source+"_after_mode1_before_typed_op24",
); err != nil {
return err
}
if policy.commitTransitionEarly || session.initialTownRouteStage < currentInitialTownRouteProfessionStateSent {
// This is state reconstruction only: op19 has already installed the normal
// skill snapshot, so publish exactly one durable op205 without replaying a
// quest-finish ACK or any expert-skill acquisition packet.
if err := s.sendCurrentExpertJobInfoForCharacter(session, character, false); err != nil {
return fmt.Errorf("restore initial-town expert-job state after skill snapshot: %w", err)
}
if session.initialTownRouteStage < currentInitialTownRouteProfessionStateSent {
session.initialTownRouteStage = currentInitialTownRouteProfessionStateSent
}
s.logGameEvent(session, "game-initial-town-expert-job-state-restored",
"source", source,
"char_id", charID,
"order", "mode0_mode1_op19_then_state_only_op205",
"qualification_packets_replayed", false)
}
// mode0/mode1 have created the local actor, which is what the client's
// option applier is gated on. The scene-init op390 landed before that and
// stored the table without rebuilding the skill-cooldown reminder list, so
// replay the same body once here.
if err := s.sendCurrentCharacterOptionSnapshotForActor(session, source+"_after_mode1_local_actor"); err != nil {
return fmt.Errorf("replay initial-town character option snapshot after mode1: %w", err)
}
// Full mode1 has now created the equipped creature object. Publish the
// absolute creature table and growth state before the typed town transition
// so the first visible pet panel already has satiety and experience.
if err := s.sendSelectedCreatureInitialStateAfterMode1(
session,
source+"_after_full_mode1_before_typed_op24",
); err != nil {
return err
}
// Do not send class0/op2 mode3 during login. The current EXE routes that
// packet through sub_2008600, which opens the personal-information panel;
// mode1 already carries the same authoritative actor state and equipment.
s.logPacketEvent("game-initial-town-mode3-suppressed",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", charID,
"object_key", objectKey,
"adventure_total_point", adventureSummary.TotalPoint,
"adventure_manage_level", adventureSummary.ManageLevel,
"stat_blob_len", 92,
"reader", "current_exe_sub_2008600",
"reason", "mode3_is_user_initiated_personal_information_panel_route")
// All five real op13 containers were installed immediately after the select
// ACK. Generic op14 remains forbidden during initialization because current
// EXE sub_1D73120 hardcodes the item-install notification flag to one. The
// only exceptions are the guarded list-3/slot-32 medal rehydrated above and
// the existing list-3/slot-26 creature row: their mode1-created objects each
// require that specific callback to restore durable dynamic state.
// F1 "All" is rebuilt by the current op21 acceptable/definition list,
// while op574 owns the live active triggers. Both handlers require the
// selected actor and its complete mode1 equipment model to exist.
if err := s.sendCurrentInitialTownQuestSnapshotsLocked(session, source+"_after_mode1_and_op19_before_typed_op24"); err != nil {
return err
}
// op1340 has already populated the model before mode1 consumes it. Keep
// op1346 after the full actor state: it remains the actor-bound
// notification for the account display name and overhead/UI views.
if err := s.sendCurrentAdventureActorRefreshFromAccount(session, objectKey, source+"_after_adventure_info_actor_bound"); err != nil {
return err
}
// op1340/op1346 populate account and actor adventure models but carry no
// represent-account name text. Publish op1371 after mode1 has bound the
// actor and before op24 makes the town scene visible.
representName, _, _, representErr := s.currentRepresentAccountIdentity(ctx, session)
if representErr != nil {
s.logGameEvent(session, "game-adventure-overhead-name-initial-skipped",
"source", source,
"reason", "represent_account_identity_unavailable",
"error", representErr)
} else if representName != "" {
if err := s.sendRepresentAccountNameState(
session,
representName,
false,
source+"_after_adventure_actor_bound_before_typed_op24",
); err != nil {
return err
}
}
if err := s.sendSelectedCurrentEpicProductionInfo(
session,
source+"_after_actor_bound_before_scene_commit",
); err != nil {
return err
}
insertBody := s.buildCurrentInsertOverseerBodyForSession(ctx, session, buildCurrentInsertOverseerBody())
insertPacket := csharpSelectInitPacket{
class: 0,
msgID: uint16(dnfenum.CmdPacketInsertOverseer),
kind: csharpLongHengSceneBootstrapKind,
}
if err := s.sendCSharpSelectInitPacket(session, insertPacket, insertBody); err != nil {
return err
}
if policy.deferClearQuestListUntilLocationNotifications {
s.logGameEvent(session, "game-initial-town-clear-quest-list-deferred",
"source", source,
"char_id", charID,
"reason", "current_exe_op356_must_follow_position_area_notifications_before_op24")
} else {
clearQuestListBody, err := s.buildCurrentClearQuestListTransportBodyForSession(
ctx,
session,
source+"_after_actor_and_quest_snapshots_before_scene_commit",
)
if err != nil {
return err
}
if err := s.sendGameUpperFixed16Transport(
session,
currentClearQuestListMsgID,
clearQuestListBody,
0,
1,
true,
currentClearQuestListTransportCodec,
); err != nil {
return err
}
}
// Keep the cancelled loading-cover boundary as a zero-duration seam.
s.waitCurrentInitialTownSceneCommitLocked(session, characterID)
if policy.includeReportClientSpec {
if err := s.sendCurrentSceneLayoutCommit(session, source+"_after_mode1_before_scene_layout"); err != nil {
return err
}
} else {
s.logGameEvent(session, "game-channel-reconnect-report-client-spec-skipped",
"source", source,
"char_id", charID,
"object_key", objectKey,
"reason", "current_exe_cross_channel_page_state_crashes_in_op124_when_actor_binding_is_not_page_owned")
}
partyState := runtimePartyStateSnapshot(session)
// op9 is the party/scene-manager record, not the selected actor's mode0 or
// mode1 owner. Keep it on the connected channel so the client clears or
// registers the global party actor in the same table used by op153/op11.
partyOwner := currentPartySceneOwnerContext(session)
op9Body := buildCurrentInitialTownOp9Body(session, objectKey, character, hasCharacter, charName)
op9Kind := currentSceneOp9ActorRemoveKind
bodySource := "current_exe_sub_1D64CA0_kind3_remove_party_manager_record"
if partyState.PartyID > 0 {
// Active-party sessions receive the complete member projection from the
// runtime party broadcaster. Retain a kind-0 registration here only for
// that explicitly active state; an ordinary partyless login must not
// create the client's global one-person party marker.
op9Body = buildCurrentSceneOp9ActorDisplayBodyInContext(
objectKey,
character,
hasCharacter,
charName,
partyOwner,
)
op9Kind = currentSceneOp9ActorDisplayKind
bodySource = "current_exe_sub_1D64CA0_kind0_active_party_registration"
}
s.logPacketEvent("game-initial-town-op9-party-state-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", charID,
"object_key", objectKey,
"owner_channel", partyOwner,
"party_id", partyState.PartyID,
"record_kind", op9Kind,
"body_len", len(op9Body),
"body_source", bodySource)
if err := s.sendCurrentInitialTownActorDisplayAndPlacementLocked(session, source, op9Body); err != nil {
return err
}
// Reapply the weapon-effect word after the typed transition/actor placement.
// The earlier hydration runs before the final visible town actor exists, so
// a create/rebuild can otherwise leave the rune object at effect id 0.
if err := s.sendSelectedEquippedEquipmentEffectRuneRefresh(
session,
source+"_after_typed_op24_actor_display",
); err != nil {
return err
}
if session.initialTownRouteCharacterID == charID {
switch {
case session.initialTownRouteStage == currentInitialTownRouteActorBound:
session.initialTownRouteStage = currentInitialTownRoutePlayerStatePrepared
case session.initialTownRouteStage >= currentInitialTownRouteTransitionSent &&
session.initialTownRouteStage < currentInitialTownRoutePlayerStateSent:
if err := s.commitPreparedInitialTownSkillInfoAfterTransition(
session,
source+"_after_early_typed_op24",
); err != nil {
return err
}
session.initialTownRouteStage = currentInitialTownRoutePlayerStateSent
session.townSceneReadyCharacterID = charID
}
}
stage = session.initialTownRouteStage
s.logGameEvent(session, "game-initial-town-player-state-finished",
"source", source,
"char_id", charID,
"object_key", objectKey,
"route_stage", stage,
"next_owner", "typed_op24_final_transition_commit")
return nil
}
func buildCurrentInitialTownOp9Body(
session *gameSession,
objectKey uint16,
character dnfrepo.CharacterRecord,
hasCharacter bool,
characterName string,
) []byte {
partyOwner := currentPartySceneOwnerContext(session)
if runtimePartyStateSnapshot(session).PartyID > 0 {
return buildCurrentSceneOp9ActorDisplayBodyInContext(
objectKey,
character,
hasCharacter,
characterName,
partyOwner,
)
}
return buildCurrentSceneOp9ActorRemovalBodyInContext(objectKey, partyOwner)
}
func (s *Service) sendCurrentInitialTownActorDisplayAndPlacementLocked(
session *gameSession,
source string,
op9Body []byte,
) error {
if session == nil || len(op9Body) == 0 {
return nil
}
if !session.initialTownActorDisplaySent {
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRecoverStamina), op9Body, 0); err != nil {
return err
}
session.initialTownActorDisplaySent = true
}
if !session.initialTownActorPlacementSent {
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketRequestBlacklist),
buildCurrentSceneActorPlacementBody(),
0,
); err != nil {
return err
}
session.initialTownActorPlacementSent = true
}
s.logGameEvent(session, "game-initial-town-actor-display-placement-committed",
"source", source,
"char_id", session.selectedCharacterID,
"op9_sent", session.initialTownActorDisplaySent,
"op120_sent", session.initialTownActorPlacementSent)
return nil
}
// sendCurrentInitialTownClearQuestListAfterLocationLocked sends the same
// repository-backed op356 snapshot used by the old bootstrap, but at the
// current client's proven pre-op24 location boundary.
func (s *Service) sendCurrentInitialTownClearQuestListAfterLocationLocked(
session *gameSession,
source string,
) error {
if session == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
body, err := s.buildCurrentClearQuestListTransportBodyForSession(ctx, session, source)
if err != nil {
return err
}
return s.sendGameUpperFixed16Transport(
session,
currentClearQuestListMsgID,
body,
0,
1,
true,
currentClearQuestListTransportCodec,
)
}