Files
benilla-octo/crates/benilla-app/src/net/apply.rs
T
SamandClaude Fable 5 d16d33dfca world books open, and anvils stop offering a cursor gear
- clicking a book in the world opens it and turns its pages: letters and world books share one reader
- the cursor's object rules are the client's own 31-row strategy table now, so decorative objects stop pretending to be usable
- trainer and tradeskill windows draw each row's icon by the reference's own law, and the trainer's filter dropdown toggles again
- water color follows the zone's light blend, so lakes stop snapping between area tints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:03:35 +07:00

1385 lines
60 KiB
Rust

//! The per-frame wire→ECS bridge systems: [`apply_net_updates`] drains the inbound
//! [`SessionEvent`] channel into real entities (spawn/move/despawn, descriptor merges, splines,
//! teleports, clock), and [`tag_self_player`] marks our own streamed entity. The parent module
//! owns the channel/type surface; this module owns the event application.
use std::collections::HashMap;
use benilla_protocol::{ObjectFields, SessionEvent};
use bevy::prelude::*;
use super::{
AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage,
Guid, GuidIndex, LoggedOutMessage, NetCommands, NetEvents, NetStatus, ObjectStore,
PendingTransfer, RemoteMotion, Reputations, SelfGuid, SelfPlayer, ServerSoundMessage,
ServerTime, TeleportMessage, WeatherMessage, WorldportMessage,
};
mod anim;
mod chat;
mod combat;
mod combat_log;
mod death;
mod group;
mod loot;
mod mail;
mod mount;
mod names;
mod npc;
mod objects;
mod pet;
mod quests;
mod session;
mod spells;
mod trade;
mod world;
// The arm families, split out of the dispatch match below (each `pub(super)` fn is one arm's
// body; the match stays the dispatcher, one call per arm — see the child modules).
use loot::{
inventory_failure, item_push_result, item_template, loot_all_passed, loot_clear_money,
loot_error, loot_money_notify, loot_release_response, loot_removed, loot_response, loot_roll,
loot_roll_won, loot_start_roll,
};
use quests::{
quest_complete, quest_detail, quest_failed, quest_giver_failed, quest_giver_invalid,
quest_giver_status, quest_greeting, quest_log_full, quest_objective_item, quest_objective_kill,
quest_objectives_complete, quest_offer, quest_progress, quest_template,
};
use spells::{
action_buttons, aura_duration, cancel_auto_repeat, cast_result, channel_start, channel_update,
clear_cooldown, cooldown_cheat, cooldown_event, item_cooldown, learned_spell, spell_book,
spell_chain_targets, spell_cooldowns, spell_delayed, spell_failed_other, spell_go, spell_start,
superceded_spell,
};
/// Which unit's cooldown store a wire cooldown packet addresses (decision 0982).
///
/// All four of them (`SMSG_SPELL_COOLDOWN`, `_COOLDOWN_EVENT`, `_CLEAR_COOLDOWN`,
/// `_COOLDOWN_CHEAT`) carry a caster guid, and until the pet bar existed all four answered it the
/// same way: "is it us? then apply, else drop" — four copies of a self-only assumption, each
/// inside its own arm. Since the server sends a pet's cooldowns on the pet's guid, that
/// assumption silently discarded every one of them. Resolving the guid ONCE, here, is what let the
/// pet bar sweep for real without a second copy of any arm; it also matches the reference, whose
/// `SMSG_COOLDOWN_CHEAT` handler wipes "the self/pet cooldown list" off exactly this test.
///
/// `None` = a guid we hold no store for (another player's pet, a stale packet): dropped, as the
/// client drops an unknown guid.
fn addressed_store<'a>(
caster: u64,
self_guid: &SelfGuid,
player: &'a mut crate::cooldowns::Cooldowns,
pet: &'a mut crate::ui_pet::PetBar,
) -> Option<&'a mut crate::cooldowns::Cooldowns> {
if self_guid.0 == Some(caster) {
Some(player)
} else if pet.has_bar() && pet.spells.pet_guid == caster {
Some(&mut pet.cooldowns)
} else {
None
}
}
// ── The per-frame bridge systems ─────────────────────────────────────────────────────────────────
/// Drain the inbound event channel and mutate real ECS entities: spawn on create, move existing,
/// despawn on remove, attach/clear movement splines, and surface teleport/worldport/clock changes.
// The tuple params below batch resources to stay under Bevy's 16-SystemParam ceiling; clippy reads
// the 5-element ResMut tuples as "very complex types", but a named alias per tuple would be less
// legible than the inline, commented groups.
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn apply_net_updates(
mut commands: Commands,
events: Res<NetEvents>,
mut index: ResMut<GuidIndex>,
mut self_guid: ResMut<SelfGuid>,
mut status: ResMut<NetStatus>,
mut server_time: ResMut<ServerTime>,
mut reputations: ResMut<Reputations>,
mut transforms: Query<&mut Transform>,
mut stores: Query<&mut ObjectStore>,
mut remote_motion: Query<&mut RemoteMotion>,
// One tuple param (the 16-SystemParam ceiling): the session-lifecycle one-shot writers — the
// player's teleport/worldport snaps + the glue-screen edges (decision 0193).
session_msgs: (
MessageWriter<TeleportMessage>,
MessageWriter<WorldportMessage>,
MessageWriter<CharListMessage>,
MessageWriter<CharActionResultMessage>,
MessageWriter<EnteredWorldMessage>,
MessageWriter<LoggedOutMessage>,
MessageWriter<super::SpeedChangeMessage>,
// The ack'd movement-mode family (decisions 0308, 0866): a mode the server granted our
// mover, which the player controller applies and answers with its live pose.
MessageWriter<super::MoveModeMessage>,
// The login screen's dialog + reconnect-policy feed (decision 0539).
MessageWriter<super::LoginStageMessage>,
MessageWriter<super::LoginFailedMessage>,
MessageWriter<super::DisconnectedMessage>,
// The server's own answer to a GM dot-command, readable rather than only logged — the
// probe shield's confirmation channel (decision 0677).
MessageWriter<super::ServerSaidMessage>,
// A `MSG_MOVE_*` the server addressed to OUR mover (decision 0725): a pose it wrote, with
// no handshake — the controller applies it in `player::wire_in`.
MessageWriter<super::SelfMoveMessage>,
),
// One tuple param (the 16-SystemParam ceiling): the ask-once query caches + the gossip/merchant
// state the net drain fills for the NPC-interaction windows (decision 0081).
caches: (
ResMut<crate::names::NameCache>,
ResMut<crate::items::Items>,
ResMut<crate::ui_gossip::GossipState>,
ResMut<crate::ui_merchant::MerchantOpen>,
ResMut<crate::ui_trainer::TrainerOpen>,
// Nested triple (the tuple is at the 16-param ceiling): the loot window state, the
// client-local loot-target latch (the kneel's self trigger, decision 0515), and the open
// group-loot rolls (decision 0591).
(
ResMut<crate::ui_loot::LootState>,
ResMut<crate::ui_loot::LootLatch>,
ResMut<crate::ui_loot_roll::LootRolls>,
),
ResMut<crate::ui_chat::ChatLog>,
ResMut<crate::ui_quest::QuestGiver>,
ResMut<crate::ui_quest_log::QuestLog>,
ResMut<crate::go_templates::GameObjectTemplates>,
ResMut<crate::net::HomeBind>,
ResMut<crate::net::Proficiencies>,
ResMut<crate::net::DroppedOpcodes>,
// The death arc's wire-fed store (decision 0308): reclaim expiry, corpse location,
// resurrect offer, the spirit-healer confirm.
ResMut<crate::death::DeathNet>,
// The party/raid roster mirror + its composed system lines (decision 0434).
ResMut<crate::ui_party::GroupState>,
// The taxi-map session (decision 0484 phase 1) + the mailbox session + its login-scoped
// arrival countdown (decision 0544) + the player-trade session (decision 0592) + the bank
// session and its purchase-refusal queue (decision 0604) + the world-state table the
// NPC-text `$<n>w` tokens read + the duel session (decision 0633), grouped to stay under
// Bevy's 16-SystemParam ceiling (this tuple's 16th and last slot).
(
ResMut<crate::ui_taxi::TaxiState>,
ResMut<crate::ui_mail::MailOpen>,
ResMut<crate::ui_mail::MailPending>,
ResMut<crate::ui_trade::TradeSession>,
ResMut<crate::ui_bank::BankOpen>,
ResMut<crate::ui_bank::BankErrors>,
ResMut<crate::world_state::WorldStates>,
ResMut<crate::ui_duel::DuelState>,
ResMut<crate::ui_social::SocialState>,
// The pending logout/quit (decision 0674): the server's response and cancel-ack land
// here, and `crate::ui_logout` turns them into the countdown dialog.
ResMut<crate::ui_logout::LogoutState>,
// The shared AreaTable catalog — the exploration arm's area-id → name resolve
// (decision 0828) — and the race-keyed discovery-jingle catalog (decision 0829).
// Either absent if its DBC failed to load.
Option<Res<crate::area::AreaTableRes>>,
Option<Res<crate::sound::ExplorationSounds>>,
// The mirror-timer queue (decision 0874): the breath/fatigue START/PAUSE/STOP edges,
// drained into the FrameXML bars by `crate::ui_mirror`.
ResMut<crate::ui_mirror::MirrorTimerFeed>,
// The pet action bar's server-authoritative state + its own cooldown store
// (decision 0982), replaced wholesale on every `SMSG_PET_SPELLS`; and the by-key red
// error queue its refused-order feedback rides (the `DisplayError` route, resolved
// through the VM's own GlobalStrings by `ui_action::feed_actions`).
ResMut<crate::ui_pet::PetBar>,
ResMut<crate::ui_action::UiErrorKeys>,
// The ask-once book-page cache (decision 1105) — every readable's text, keyed by
// `PageText` id; the reader session repaints off it.
ResMut<crate::ui_item_text::PageTexts>,
),
),
// One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors
// and the cast-bar feed (decision 0137), plus the item-lock bookkeeping the inventory-failure
// arm also drains (decision 0216 §4 / 0218 §3 — this apply site has no `UiScript` to fire
// `ITEM_LOCK_CHANGED` through, so the transitioned slots queue in `LockClearedByFailure` for
// the container feed to pick up).
mut ui_actions: (
ResMut<crate::ui_action::PlayerActions>,
// Nested pair (the tuple is at the 16-param ceiling): the cast + mount error queues,
// both drained into the red error line by `ui_action::feed_actions`.
(
ResMut<crate::ui_action::CastErrors>,
ResMut<crate::ui_action::MountErrors>,
),
ResMut<crate::ui_items::EquipErrors>,
ResMut<crate::ui_merchant::MerchantErrors>,
ResMut<crate::ui_loot::LootErrors>,
ResMut<crate::ui_cast::CastBarFeed>,
ResMut<crate::pending_item_ops::PendingItemOps>,
ResMut<crate::pending_item_ops::LockClearedByFailure>,
ResMut<crate::ui_trainer::TrainerErrors>,
ResMut<crate::ui_cast::PendingCast>,
// The cooldown store + the Spell.dbc catalog its wire laws read, and the live
// auto-repeat state the bar's flash rides (decision 0137 phase 4).
ResMut<crate::cooldowns::Cooldowns>,
Option<Res<crate::ui_action::Spells>>,
ResMut<crate::ui_action::AutoRepeatActive>,
// Our own running channel (the IsCurrentAction channel leg, decision 0137 phase 4).
ResMut<crate::ui_cast::ActiveChannel>,
// Pre-formatted red error lines (the death durability notice — drained by the
// container feed beside EquipErrors).
ResMut<crate::ui_items::UiErrorLines>,
// The queued on-next-swing strike (the melee-slot half of the cast tracking) — the
// wire resolves it here: GO fires it, a failing result/interrupt kills it.
ResMut<crate::ui_cast::QueuedMeleeSpell>,
),
net_commands: Res<NetCommands>,
// One tuple param (Bevy's 16-SystemParam ceiling): the audio + combat/cast bridge writers, the
// cast-state read the spell-id-keyed `Casting` reap needs (decision 0107), and the floating
// combat-text feed (decision 0137 phase 2).
mut audio: (
MessageWriter<ServerSoundMessage>,
MessageWriter<WeatherMessage>,
MessageWriter<EmoteMessage>,
MessageWriter<crate::creature_anim::SwingMessage>,
Query<&crate::creature_anim::Casting>,
MessageWriter<crate::creature_anim::CastEvent>,
MessageWriter<crate::creature_anim::SpellGoTargets>,
MessageWriter<crate::combat_text::CombatTextSpawn>,
MessageWriter<crate::creature_anim::SwingImpact>,
MessageWriter<crate::creature_anim::SwingFlush>,
MessageWriter<crate::go_anim::GoLidOpen>,
// The aggro/alert vocal flare + the pushed-kit play (decision 0280).
MessageWriter<AiReactionMessage>,
MessageWriter<crate::creature_anim::KitPush>,
// The remote landing predictor's report (decision 0415): a relayed FALL_LAND fires the
// grunt + dust puff for an observed mover, the way the self controller does for us.
MessageWriter<crate::creature_anim::HardLanding>,
// An observed rider's flourish (`SMSG_MOUNTSPECIAL_ANIM`, decision 0441 P2) — the
// unit → mount-child hop happens in `creature_anim::flourish_to_anim`.
MessageWriter<crate::creature_anim::MountFlourish>,
// The UNIT_COMBAT event feed (the portrait hit indicator, decision 0576) + the
// COMBAT_TEXT_UPDATE feed (the center combat text, decision 0578) — the spell arms'
// self-facing twins of the floating-text spawn — and the sheath setter's queue, which the
// ATTACKERSTATEUPDATE arm's melee auto-draw writes into. Nested: the tuple is at the
// 16-param ceiling.
(
MessageWriter<crate::ui_unit::UnitCombatFeedback>,
MessageWriter<crate::ui_unit::CombatTextEvent>,
MessageWriter<crate::creature_anim::SheathRequest>,
// The GO one-shot Custom play (`SMSG_GAMEOBJECT_CUSTOM_ANIM` — the bobber's bite
// splash, decision 1086), the step-8 sibling of the `GoLidOpen` writer above.
MessageWriter<crate::go_anim::GoCustomAnim>,
),
),
// The aura feed's duration side-table + the clock to stamp arrivals (decisions 0255/0257): the
// self-only `SMSG_UPDATE_AURA_DURATION` lands here keyed by raw slot, timestamped for the
// `ui_aura` slot-join — plus the ping clock the Pong arm measures round trips against.
// Grouped as a tuple to stay under Bevy's 16-SystemParam ceiling.
//
// The stamp clock is `Time<Real>`, NOT the default virtual one, and both its readers
// (`aura_duration`, `corpse_reclaim_delay`) are the reason: a span the SERVER sends us counts
// down in real seconds, so it must be stamped and read on a real clock. `Time<Virtual>` clamps
// at 250 ms per frame (`max_delta`), which is right for simulation — a 2 s hitch must not
// teleport animations — and wrong here: every long frame would quietly ADD that much to every
// buff timer, so an aura would vanish with seconds still showing on its clock. Measured on a
// hitchy run: the virtual clock lost 20 s against real in 33 s (decision 0846).
mut aura: (
ResMut<crate::ui_aura::AuraDurations>,
Res<Time<Real>>,
Res<super::PingShared>,
Query<&mut super::UnitSpeeds>,
// The PlayAnimation call-order counter (`creature_anim::PlaySeq`): every
// animation-bearing message this drain emits stamps `next()`, in packet order.
ResMut<crate::creature_anim::PlaySeq>,
// The EnvironmentalDamage.dbc 6-slot table (damage type → SpellVisualKit) the
// `SMSG_ENVIRONMENTALDAMAGELOG` arm reads — the fall-landing dust puff.
Option<Res<crate::creature_anim::EnvDamageTable>>,
// The far-teleport latch + the armed-transport lens for the worldport's spare
// predicate (decision 0455: a boat whose path touches the destination map survives
// the purge; the TRANSFER_PENDING transport block routes NEW_WORLD's coordinates).
ResMut<PendingTransfer>,
Query<&crate::transport::Transport>,
// The REAL-time clock the relayed-move replay runs on (decisions 0601/0615): a remote's
// fire-time is stamped against this, and `drain_pending_moves`/`extrapolate_remote_units`
// read the same clock. Virtual time's `max_delta` clamp falls behind real time under
// occlusion throttling and would displace the whole replay schedule.
Res<Time<bevy::time::Real>>,
),
) {
// A `&mut` to the counter itself (deref-coerced through the `ResMut`), so the arms that stamp
// it *conditionally* can take it by reference and only advance it when they emit.
let play_seq: &mut crate::creature_anim::PlaySeq = &mut aura.4;
let (
mut names,
mut items,
mut gossip,
mut merchant,
mut trainer_open,
(mut loot, mut loot_latch, mut loot_rolls),
mut chat_log,
mut quest,
mut quest_log,
mut go_templates,
mut home_bind,
mut proficiencies,
mut dropped,
mut death_net,
mut group,
(
mut taxi,
mut mail_open,
mut mail_pending,
mut trade_session,
mut bank_open,
mut bank_errors,
mut world_states,
mut duel,
mut social,
mut logout,
area_table,
exploration_sounds,
mut mirror_timers,
mut pet_bar,
mut ui_error_keys,
mut page_texts,
),
) = caches;
let (
mut teleports,
mut worldports,
mut char_lists,
mut char_actions,
mut entered_world,
mut logged_out,
mut speed_changes,
mut move_modes,
mut login_stages,
mut login_failures,
mut disconnects,
mut server_said,
mut self_moves,
) = session_msgs;
// Descriptor seeds/deltas for objects created *earlier in this same drain* can't land on their
// entities yet (the spawn `Command` hasn't run), so they accumulate here and flush once at the end.
// This also removes a latent clobber: a plain per-delta `insert` on a not-yet-spawned entity would
// overwrite an earlier partial rather than merge it (decision 0061).
let mut pending: HashMap<u64, ObjectFields> = HashMap::new();
for ev in events.0.try_iter() {
match ev {
SessionEvent::LoginStage { stage } => session::login_stage(stage, &mut login_stages),
SessionEvent::LoginFailed {
code,
reason,
terminal,
} => session::login_failed(code, reason, terminal, &mut login_failures),
SessionEvent::CharacterList { characters, realm } => {
session::character_list(characters, realm, &mut status, &mut char_lists)
}
SessionEvent::CharActionResult { action, code } => {
session::char_action_result(action, code, &mut char_actions)
}
SessionEvent::CinematicTriggered { cinematic_id } => {
session::cinematic_triggered(cinematic_id, &net_commands)
}
SessionEvent::Connected {
self_guid: guid,
name,
} => session::connected(
guid,
name,
&mut self_guid,
&mut status,
&mut names,
&mut entered_world,
),
SessionEvent::LoggedOut => {
session::logged_out(&mut commands, &mut index, &mut self_guid, &mut logged_out)
}
// The logout arc's two narration packets (decision 0674) — `crate::ui_logout` owns the
// decision table; this is only the hand-off.
SessionEvent::LogoutResponse { reason, instant } => {
logout.apply_response(reason, instant)
}
SessionEvent::LogoutCancelled => logout.apply_cancelled(),
SessionEvent::Disconnected { reason } => {
session::disconnected(
reason,
&mut commands,
&mut index,
&self_guid,
&mut status,
&mut names,
&mut items,
&mut gossip,
&mut merchant,
&mut trainer_open,
&mut loot,
&mut loot_latch,
&mut loot_rolls,
&mut chat_log,
&mut quest,
&mut quest_log,
&mut death_net,
&mut group,
&mut taxi,
&mut mail_open,
&mut mail_pending,
&mut trade_session,
&mut bank_open,
&mut duel,
&mut social,
&mut aura.6,
&mut disconnects,
);
}
SessionEvent::ObjectCreate {
guid,
kind,
display_id,
position,
orientation,
scale,
speeds,
transport_progress,
transport,
spline,
fields,
} => {
death::note_corpse(guid, kind, &fields, &self_guid, &mut death_net);
objects::object_create(
guid,
kind,
display_id,
position,
orientation,
scale,
speeds,
transport_progress,
transport,
spline,
fields,
&mut commands,
&mut index,
&mut transforms,
&mut stores,
&mut pending,
&mut names,
&mut go_templates,
&net_commands,
)
}
SessionEvent::ItemCreate {
guid,
container,
fields,
} => objects::item_create(guid, container, fields, &mut items),
SessionEvent::ObjectMove {
guid,
position,
orientation,
} => objects::object_move(
guid,
position,
orientation,
&mut commands,
&index,
&mut transforms,
),
SessionEvent::UnitMove {
guid,
position,
orientation,
flags,
pitch,
time,
heartbeat,
fall_time,
jump,
transport,
} => {
// The scheduled-replay law (decisions 0601/0615): `unit_move` runs the mover's own
// replay chain over this packet's wire stamp to get its client fire-time, then
// applies it now if due, else queues it on the unit for `drain_pending_moves`.
let now_ms = aura.8.elapsed_secs_f64() * 1000.0;
objects::unit_move(
guid,
crate::net::motion::RelayMove {
wire_ms: time,
position,
orientation,
flags,
pitch,
fall_time,
jump,
transport,
heartbeat,
},
now_ms,
&mut commands,
&index,
&self_guid,
&mut remote_motion,
&mut transforms,
&mut audio.13,
&mut self_moves,
);
}
SessionEvent::ObjectValues { guid, fields } => {
objects::object_values(guid, fields, &index, &mut stores, &mut pending, &mut items)
}
SessionEvent::ObjectDestroyed(guid) => {
death::forget_corpse(guid, &mut death_net);
objects::object_destroyed(guid, &mut commands, &mut index, &mut items)
}
SessionEvent::ObjectsRemoved(guids) => {
objects::objects_removed(guids, &mut commands, &mut index)
}
SessionEvent::MonsterMove {
guid,
start,
spline_id,
path,
facing,
stop,
duration_ms,
flying,
} => objects::monster_move(
guid,
start,
spline_id,
path,
facing,
stop,
duration_ms,
flying,
&mut commands,
&index,
&mut transforms,
),
SessionEvent::Teleport {
guid,
counter,
position,
orientation,
} => session::teleport(
guid,
counter,
position,
orientation,
&self_guid,
&mut teleports,
),
SessionEvent::Worldport {
map_id,
position,
orientation,
needs_ack,
} => session::worldport(
map_id,
position,
orientation,
needs_ack,
&mut commands,
&mut index,
&mut aura.6,
&aura.7,
&mut worldports,
),
SessionEvent::TransferPending {
map_id,
transport_entry,
} => session::transfer_pending(map_id, transport_entry, &mut aura.6),
SessionEvent::TransferAborted { reason } => {
session::transfer_aborted(reason, &mut aura.6)
}
SessionEvent::TimeSpeed {
hours,
minutes,
day_serial,
timescale,
} => session::time_speed(hours, minutes, day_serial, timescale, &mut server_time),
SessionEvent::Reputations { standings } => {
session::reputations(standings, &mut reputations)
}
SessionEvent::ReputationDelta { standings } => {
session::reputation_delta(standings, &mut reputations, &mut quest)
}
SessionEvent::BindPoint { area } => home_bind.0 = Some(area),
SessionEvent::Proficiency {
item_class,
subclass_mask,
} => {
proficiencies.0.insert(item_class, subclass_mask);
}
SessionEvent::PlayerName {
guid,
name,
race,
class,
gender,
} => names::player_name(guid, name, race, class, gender, &mut names),
SessionEvent::PetName { pet_number, name } => {
names::pet_name(pet_number, name, &mut names)
}
SessionEvent::CreatureName {
entry,
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
civilian,
racial_leader,
} => names::creature_name(
entry,
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
civilian,
racial_leader,
&mut names,
),
SessionEvent::GameObjectInfo {
entry,
type_id,
display_id,
name,
data,
} => {
objects::gameobject_info(entry, type_id, display_id, name, &data, &mut go_templates)
}
SessionEvent::GameObjectCustomAnim { guid, anim_id } => {
objects::gameobject_custom_anim(guid, anim_id, &mut audio.15 .3)
}
SessionEvent::FishNotHooked => loot::fish_verdict(false, &mut ui_error_keys),
SessionEvent::FishEscaped => loot::fish_verdict(true, &mut ui_error_keys),
SessionEvent::PlaySound { sound_id } => world::play_sound(sound_id, &mut audio.0),
SessionEvent::PlayMusic { music_id } => world::play_music(music_id, &mut audio.0),
SessionEvent::PlayObjectSound { sound_id, guid } => {
world::play_object_sound(sound_id, guid, &index, &mut audio.0)
}
SessionEvent::Weather {
weather_type,
grade,
sound_id,
instant,
} => world::weather(weather_type, grade, sound_id, instant, &mut audio.1),
SessionEvent::TextEmote { guid, text_emote } => {
anim::text_emote(guid, text_emote, &index, &mut audio.2)
}
SessionEvent::Emote { guid, emote_id } => {
anim::emote(guid, emote_id, &index, &mut audio.2)
}
// The spell-book/action-bar pair → the action store the UI feed reads
// (`crate::ui_action`), sent once at login (and the bar again on server-side edits).
SessionEvent::SpellBook {
spell_ids,
cooldowns,
} => spell_book(spell_ids, cooldowns, &mut ui_actions.0, &mut ui_actions.10),
SessionEvent::ActionButtons { buttons } => action_buttons(buttons, &mut ui_actions.0),
SessionEvent::SpellLearned { spell_id } => learned_spell(spell_id, &mut ui_actions.0),
SessionEvent::SpellSuperceded {
old_spell_id,
new_spell_id,
} => superceded_spell(old_spell_id, new_spell_id, &mut ui_actions.0),
SessionEvent::CastResult {
spell_id,
success,
reason,
} => cast_result(
spell_id,
success,
reason,
&mut commands,
&self_guid,
&index,
&mut ui_actions.1 .0,
&audio.4,
&mut audio.5,
&mut ui_actions.5,
&mut ui_actions.9,
&mut ui_actions.15,
&mut ui_actions.10,
&mut ui_actions.12,
ui_actions.11.as_deref(),
&net_commands,
play_seq.next(),
),
SessionEvent::InventoryFailure {
reason,
required_level,
item_guid,
bag_slot,
} => inventory_failure(
reason,
required_level,
item_guid,
bag_slot,
&mut ui_actions.2,
&mut ui_actions.6,
&mut ui_actions.7,
),
SessionEvent::Chat(m) => {
chat::chat(m, &mut chat_log, &social, &net_commands, &mut server_said)
}
SessionEvent::ChannelNotify {
notice,
channel,
tail,
} => chat_log.push_channel_notice(notice, channel, &tail),
SessionEvent::ChannelList {
channel, members, ..
} => chat::channel_list(channel, &members, &mut chat_log),
SessionEvent::ChatPlayerNotFound { name } => {
chat::chat_player_not_found(&name, &mut chat_log)
}
SessionEvent::ChatWrongFaction => chat::chat_wrong_faction(&mut chat_log),
SessionEvent::Notification { text } => chat::notification(text, &mut chat_log),
SessionEvent::AreaTriggerMessage { text } => {
chat::area_trigger_message(text, &mut chat_log)
}
SessionEvent::PlayedTime { total, level } => {
chat::played_time(total, level, &mut chat_log)
}
SessionEvent::RandomRoll {
min,
max,
roll,
guid,
} => chat_log.push_roll(min, max, roll, guid),
// ── The group/party family (decision 0434 §D2, superseded by 0440) — arm bodies in
// `group` ──
SessionEvent::GroupInvite { inviter } => {
group::invited(&mut group, &mut chat_log, &inviter)
}
SessionEvent::GroupDecline { name } => {
group::declined(&mut group, &mut chat_log, &name)
}
SessionEvent::GroupUninvited => group::uninvited(&mut group, &mut chat_log),
SessionEvent::GroupLeaderChanged { name } => group::leader_changed(
&mut group,
&mut chat_log,
&name,
&self_guid,
&mut names,
&net_commands,
),
SessionEvent::GroupDestroyed => group::destroyed(&mut group, &mut chat_log),
SessionEvent::GroupList {
group_type,
own_flags,
members,
leader,
loot,
} => group::list(
&mut group,
&mut chat_log,
&mut quest,
group_type,
own_flags,
members,
leader,
loot,
),
SessionEvent::PartyCommandResult {
operation,
member,
result,
} => group::command_result(&mut group, &mut chat_log, operation, &member, result),
SessionEvent::PartyMemberStats { guid, full, info } => {
group.apply_stats(guid, full, *info)
}
SessionEvent::RaidTargetSet { icon, guid } => group.apply_raid_target(icon, guid),
SessionEvent::RaidTargetList { entries } => group.apply_raid_target_list(&entries),
// Ping + ready-check are removed for now (decision 0460); the protocol still decodes
// the wire, but the client ignores it until those features return.
SessionEvent::MinimapPing { .. }
| SessionEvent::ReadyCheckRequest
| SessionEvent::ReadyCheckAnswer { .. } => {}
// ── The duel family (decision 0633): the session mirror + the two DisplayError
// lines the handlers emit inline; the Era events fire off the mirror's edges in
// `ui_duel::feed_duel`, and the countdown ticks in its own system ──
SessionEvent::DuelRequested {
arbiter,
challenger,
} => crate::ui_duel::apply::requested(
&mut duel,
&mut chat_log,
&net_commands,
arbiter,
challenger,
self_guid.0,
social.is_ignored(challenger),
),
SessionEvent::DuelOutOfBounds => crate::ui_duel::apply::bounds(&mut duel, true),
SessionEvent::DuelInBounds => crate::ui_duel::apply::bounds(&mut duel, false),
SessionEvent::DuelComplete { started } => {
crate::ui_duel::apply::complete(&mut duel, &mut chat_log, started);
}
SessionEvent::DuelWinner {
fled,
winner,
loser,
} => crate::ui_duel::apply::winner(&mut chat_log, fled, &winner, &loser),
SessionEvent::DuelCountdown { seconds } => {
crate::ui_duel::apply::countdown(&mut duel, seconds);
}
// ── The mirror timers (decision 0874): breath / fatigue / feign-death. Pure queue
// arms — every meaning (which bar, what colour, what caption, how fast it drains)
// is resolved at the UI seam in `ui_mirror`, and the countdown itself is the
// FrameXML's own OnUpdate integration ──────────────────────────────────────────────
SessionEvent::MirrorTimerStart(start) => mirror_timers
.0
.push(crate::ui_mirror::MirrorTimerEdge::Start(start)),
SessionEvent::MirrorTimerPause { kind, paused } => mirror_timers
.0
.push(crate::ui_mirror::MirrorTimerEdge::Pause { kind, paused }),
SessionEvent::MirrorTimerStop { kind } => mirror_timers
.0
.push(crate::ui_mirror::MirrorTimerEdge::Stop { kind }),
// ── The social family (decision 0668): the friend/ignore lists, the `/who`
// answer, and the result codes that print their own chat lines. The lines and the
// Era events fire off the mirror in `ui_social::feed_social` — every one of them
// needs a NAME the drain has no cache handle for.
SessionEvent::FriendList { friends } => {
crate::ui_social::apply::friend_list(&mut social, friends)
}
SessionEvent::IgnoreList { guids } => {
crate::ui_social::apply::ignore_list(&mut social, guids)
}
SessionEvent::FriendStatus(update) => {
crate::ui_social::apply::friend_status(&mut social, update)
}
SessionEvent::WhoResults(results) => crate::ui_social::apply::who(&mut social, results),
SessionEvent::LootResponse {
guid,
loot_type,
gold,
items,
} => loot_response(guid, loot_type, gold, items, &mut loot),
SessionEvent::LootError { guid, error } => {
loot_error(guid, error, &mut ui_actions.4, &mut loot_latch)
}
SessionEvent::LootRemoved { slot } => loot_removed(slot, &mut loot),
SessionEvent::LootMoneyNotify { amount } => loot_money_notify(amount),
SessionEvent::LootClearMoney => loot_clear_money(&mut loot),
SessionEvent::LootReleaseResponse { guid } => {
loot_release_response(guid, &mut loot, &mut loot_latch)
}
SessionEvent::ItemPushResult(p) => item_push_result(p, &self_guid, &mut loot),
// ── The group-loot roll family (decision 0591) — the GroupLootFrame feed ───────────
SessionEvent::LootStartRoll(p) => loot_start_roll(p, &mut loot_rolls),
SessionEvent::LootRoll(p) => loot_roll(p, &mut loot_rolls),
SessionEvent::LootRollWon(p) => loot_roll_won(p, &mut loot_rolls),
SessionEvent::LootAllPassed(p) => loot_all_passed(p, &mut loot_rolls),
// ── The death arc (decision 0308) — arm bodies in `death` ─────────────────────────
SessionEvent::CorpseQuery {
found,
display_map,
position,
corpse_map,
} => death::corpse_query(found, display_map, position, corpse_map, &mut death_net),
SessionEvent::CorpseReclaimDelay { delay_ms } => {
death::corpse_reclaim_delay(delay_ms, aura.1.elapsed_secs_f64(), &mut death_net)
}
SessionEvent::ResurrectRequest {
caster,
name,
sickness,
has_timer,
} => death::resurrect_request(caster, name, sickness, has_timer, &mut death_net),
SessionEvent::SpiritHealerConfirm { npc } => {
death::spirit_healer_confirm(npc, &mut death_net)
}
SessionEvent::DurabilityDamageDeath => {
death::durability_damage_death(&mut ui_actions.14)
}
SessionEvent::MoveMode {
guid,
counter,
mode,
apply,
} => death::move_mode(
guid,
counter,
mode,
apply,
&self_guid,
&mut death_net,
&mut move_modes,
),
SessionEvent::ItemTemplate { entry, info } => {
item_template(entry, info.map(|b| *b), &mut items)
}
SessionEvent::AttackStart { attacker, victim } => {
combat::attack_start(attacker, victim, &mut commands, &index)
}
SessionEvent::AttackStop { attacker, victim } => {
combat::attack_stop(attacker, victim, &mut commands, &index, &mut audio.9)
}
SessionEvent::AiReaction { unit, reaction } => {
combat::ai_reaction(unit, reaction, &index, &mut audio.11)
}
SessionEvent::AttackerState(s) => combat::attacker_state(
s,
&index,
&self_guid,
&mut audio.3,
&mut audio.8,
&mut audio.15 .1,
&mut audio.15 .2,
play_seq.next(),
),
SessionEvent::SpellDamageLog(s) => combat_log::spell_damage_log(
s,
&index,
&self_guid,
&stores,
ui_actions.11.as_deref(),
&mut audio.7,
&mut audio.15 .0,
&mut audio.15 .1,
),
SessionEvent::PeriodicAuraLog(s) => combat_log::periodic_aura_log(
s,
&index,
&self_guid,
&stores,
ui_actions.11.as_deref(),
&mut audio.7,
&mut audio.15 .0,
&mut audio.15 .1,
&mut names,
&net_commands,
),
SessionEvent::SpellHealLog(s) => combat_log::spell_heal_log(
s,
&index,
&self_guid,
&mut audio.15 .0,
&mut audio.15 .1,
&mut names,
&net_commands,
),
SessionEvent::SpellEnergizeLog(s) => {
combat_log::spell_energize_log(s, &self_guid, &mut audio.15 .1)
}
SessionEvent::DamageShield(s) => combat_log::damage_shield(
s,
&index,
&self_guid,
&stores,
&mut audio.7,
&mut audio.15 .0,
),
SessionEvent::SpellLogMiss(s) => combat_log::spell_log_miss(
s,
&index,
&self_guid,
&stores,
&mut audio.7,
&mut audio.15 .0,
&mut audio.15 .1,
),
SessionEvent::XpGain(x) => {
combat_log::xp_gain(x, &index, &self_guid, &mut audio.7, &mut chat_log)
}
SessionEvent::ExplorationXp(x) => combat_log::exploration_xp(
x,
area_table.as_deref(),
exploration_sounds.as_deref(),
&index,
&self_guid,
&stores,
&mut audio.0,
&mut chat_log,
),
SessionEvent::LevelUp(l) => combat_log::level_up(l, &mut chat_log),
SessionEvent::SpellStart {
caster,
spell_id,
cast_flags,
cast_time_ms,
target,
ammo_display_id,
} => spell_start(
caster,
spell_id,
cast_flags,
cast_time_ms,
target,
ammo_display_id,
&mut commands,
&index,
&mut audio.5,
&self_guid,
&mut ui_actions.5,
&mut ui_actions.9,
ui_actions.11.as_deref(),
play_seq.next(),
),
SessionEvent::SpellGo {
caster,
spell_id,
cast_flags,
hits,
misses,
target,
go_target,
dest,
ammo_display_id,
item_caster,
} => spell_go(
caster,
spell_id,
cast_flags,
hits,
misses,
target,
go_target,
dest,
ammo_display_id,
item_caster,
&mut commands,
&index,
&audio.4,
&mut audio.5,
&mut audio.6,
&self_guid,
&stores,
&mut ui_actions.5,
&mut ui_actions.9,
&mut ui_actions.15,
&mut audio.7,
&mut audio.10,
(
&mut ui_actions.10,
ui_actions.11.as_deref(),
&mut items,
&net_commands,
&mut pet_bar,
),
play_seq.next(),
),
SessionEvent::SpellChainTargets {
caster,
spell_id,
targets,
} => spell_chain_targets(caster, spell_id, targets, &mut commands, &index),
SessionEvent::SpellFailedOther { caster, spell_id } => spell_failed_other(
caster,
spell_id,
&mut commands,
&index,
&audio.4,
&mut audio.5,
&self_guid,
&mut ui_actions.5,
&mut ui_actions.9,
&mut ui_actions.15,
play_seq.next(),
),
SessionEvent::SpellDelayed { caster, delay_ms } => spell_delayed(
caster,
delay_ms,
&self_guid,
&mut ui_actions.5,
&mut ui_actions.9,
),
SessionEvent::CancelAutoRepeat => cancel_auto_repeat(
&mut ui_actions.12,
&self_guid,
&index,
&mut commands,
&net_commands,
),
SessionEvent::SpellCooldowns { caster, cooldowns } => {
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
{
spell_cooldowns(caster, cooldowns, ui_actions.11.as_deref(), store);
}
}
SessionEvent::ItemCooldown {
item_guid,
spell_id,
} => item_cooldown(item_guid, spell_id, &items, &mut ui_actions.10),
// The temporary-enchant countdown's ONLY feed (decision 0920): park the deadline on the
// item store, which every tooltip surface reads back through `enchant_remaining_ms`.
SessionEvent::ItemEnchantTime {
item_guid,
slot,
seconds,
} => items.set_enchant_deadline(item_guid, slot, seconds),
SessionEvent::CooldownEvent { spell_id, caster } => {
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
{
cooldown_event(spell_id, caster, store);
}
}
SessionEvent::ClearCooldown { spell_id, caster } => {
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
{
clear_cooldown(spell_id, caster, store);
}
}
SessionEvent::CooldownCheat { caster } => {
if let Some(store) =
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
{
cooldown_cheat(caster, store);
}
}
// The pet action bar (decision 0982) — server-authoritative, so PET_SPELLS is a
// wholesale replace and its zero-guid form is the teardown.
SessionEvent::PetSpells(spells) => {
pet::pet_spells(*spells, ui_actions.11.as_deref(), &mut pet_bar)
}
SessionEvent::PetMode(mode) => pet::pet_mode(mode, &mut pet_bar),
SessionEvent::PetActionFeedback { reason } => {
pet::pet_action_feedback(reason, &mut ui_error_keys)
}
SessionEvent::PetCastFailed { spell_id, reason } => {
pet::pet_cast_failed(spell_id, reason, &mut ui_actions.1 .0)
}
SessionEvent::ChannelStart {
spell_id,
duration_ms,
} => channel_start(spell_id, duration_ms, &mut ui_actions.13, &mut ui_actions.5),
SessionEvent::ChannelUpdate { remaining_ms } => {
channel_update(remaining_ms, &mut ui_actions.13, &mut ui_actions.5)
}
SessionEvent::AuraDuration { slot, remaining_ms } => {
aura_duration(slot, remaining_ms, &mut aura.0, aura.1.elapsed_secs_f64())
}
SessionEvent::PlaySpellVisual { unit, kit_id } => {
anim::play_spell_visual(unit, kit_id, &index, play_seq, &mut audio.12)
}
SessionEvent::EnvironmentalDamageLog(e) => anim::environmental_damage_log(
e,
&index,
aura.5.as_deref(),
play_seq,
&mut audio.12,
),
// The gossip/vendor/trainer NPC-interaction family — arm bodies in `npc`.
SessionEvent::GossipMenu {
npc,
text_id,
options,
quests,
} => npc::gossip_menu(
npc,
text_id,
options,
quests,
&mut gossip,
&net_commands,
&index,
&stores,
),
SessionEvent::NpcGreeting { text_id, blocks } => {
npc::npc_greeting(text_id, blocks, &mut gossip, &index, &stores)
}
SessionEvent::GossipComplete => npc::gossip_complete(&mut gossip, &mut quest),
// Questgiver panels (decision 0088): fill the `QuestGiver` the quest feed
// (`crate::ui_quest`) reads. Each panel packet replaces the open view; the greeting/gossip
// quest-row clicks and the panel buttons flow back out through the quest/gossip drains.
SessionEvent::QuestGiverStatus { npc, status } => {
quest_giver_status(npc, status, &mut quest)
}
SessionEvent::QuestGreeting(list) => quest_greeting(list, &mut quest),
SessionEvent::QuestDetail(d) => quest_detail(d, &mut quest),
SessionEvent::QuestProgress(p) => quest_progress(p, &mut quest),
SessionEvent::QuestOffer(o) => quest_offer(o, &mut quest),
SessionEvent::QuestComplete(c) => quest_complete(c, &mut quest),
// Quest log (decision 0088's deferred second slice): the full template feeds the log
// window's ask-once detail cache; the `SMSG_QUESTUPDATE_*` toasts have no dedicated
// window of their own on this server (no ErrorsFrame-style transient panel yet), so they
// route through the chat window's system-line seam ([`crate::ui_chat::ChatLog`]) — the
// same seam the loot feed's refusal/receive lines use — colored SYSTEM yellow, the
// GM-feedback color.
SessionEvent::QuestTemplate(t) => quest_template(t, &mut quest_log),
SessionEvent::QuestObjectiveKill {
quest_id: _,
entry,
count,
required,
} => quest_objective_kill(entry, count, required, &mut quest),
SessionEvent::QuestObjectiveItem { item_id, count } => {
quest_objective_item(item_id, count, &mut quest)
}
SessionEvent::QuestObjectivesComplete { quest_id } => {
quest_objectives_complete(quest_id, &mut quest)
}
SessionEvent::QuestFailed { quest_id, timed } => quest_failed(
quest_id,
timed,
&mut quest_log,
&net_commands,
&mut chat_log,
&mut quest,
),
SessionEvent::QuestLogFull => quest_log_full(&mut quest),
SessionEvent::QuestGiverInvalid { reason } => quest_giver_invalid(reason, &mut quest),
SessionEvent::QuestGiverFailed { quest_id, reason } => {
quest_giver_failed(quest_id, reason, &mut quest, &mut quest_log, &net_commands)
}
SessionEvent::VendorInventory { vendor, items } => {
npc::vendor_inventory(vendor, items, &mut merchant)
}
SessionEvent::ShowBank { banker } => {
npc::show_bank(banker, &mut bank_open, &mut gossip, &mut quest)
}
SessionEvent::BuyBankSlotResult { result } => {
npc::bank_buy_slot_result(result, &mut bank_errors)
}
SessionEvent::TrainerList {
trainer,
trainer_type,
services,
greeting,
} => npc::trainer_list(trainer, trainer_type, services, greeting, &mut trainer_open),
SessionEvent::TrainerBuySucceeded { trainer, spell_id } => {
npc::trainer_buy_succeeded(trainer, spell_id, &trainer_open, &net_commands)
}
SessionEvent::TrainerBuyFailed { error, .. } => {
npc::trainer_buy_failed(error, &mut ui_actions.8)
}
SessionEvent::TaxiNodesShown {
flightmaster,
nearest_node,
known_mask,
} => npc::taxi_nodes_shown(flightmaster, nearest_node, known_mask, &mut taxi),
SessionEvent::TaxiNodeStatus { guid, known } => {
npc::taxi_node_status(guid, known, &mut commands, &index)
}
SessionEvent::ActivateTaxiReply { code } => npc::taxi_activate_reply(code, &mut taxi),
SessionEvent::NewTaxiPath => npc::taxi_new_path(&mut taxi),
SessionEvent::VendorBuyResult {
vendor,
slot,
new_count,
..
} => npc::vendor_buy_result(vendor, slot, new_count, &mut merchant),
SessionEvent::VendorBuyFailed { reason, .. } => {
npc::vendor_buy_failed(reason, &mut ui_actions.3)
}
SessionEvent::VendorSellFailed { reason, .. } => {
npc::vendor_sell_failed(reason, &mut ui_actions.3)
}
SessionEvent::ForceSpeedChange {
guid,
kind,
counter,
speed,
} => objects::force_speed_change(
guid,
kind,
counter,
speed,
&index,
&mut aura.3,
&self_guid,
&mut speed_changes,
),
SessionEvent::SpeedChanged { guid, kind, speed } => {
objects::speed_changed(guid, kind, speed, &index, &mut aura.3)
}
// ── The mount arc (decision 0441) — arm bodies in `mount` ─────────────────────────
SessionEvent::MountResult { mount, code } => {
mount::mount_result(mount, code, &mut ui_actions.1 .1)
}
SessionEvent::MountSpecial { guid } => {
mount::mount_special(guid, &self_guid, &index, &mut audio.14)
}
SessionEvent::Pong { sequence } => session::pong(sequence, &aura.2, &mut status),
SessionEvent::PacketDropped {
opcode,
unparseable,
} => session::packet_dropped(opcode, unparseable, &mut dropped),
// The mail arc (decision 0544 P1/P2/P3): the inbox/body/send-result arms fill the
// mailbox session the feed reads (`crate::ui_mail`); the arrival pair feeds
// `MailPending` (`HasNewMail()`/the minimap icon).
SessionEvent::MailList { mails } => {
mail::mail_list(mails, &mut mail_open, &net_commands)
}
SessionEvent::SendMailResult {
mail_id,
action,
error,
equip_error,
item,
} => mail::send_mail_result(
mail_id,
action,
error,
equip_error,
item,
&mut mail_open,
&net_commands,
&mut ui_actions.2,
),
SessionEvent::MailItemText { text_id, text } => {
mail::mail_item_text(text_id, text, &mut mail_open)
}
// The book-page cache (decision 1105) — one page per packet, the whole chain in
// answer to the first ask; the reader repaints off it on the next feed.
SessionEvent::PageText {
page_id,
text,
next_page_id,
} => page_texts.insert(page_id, text, next_page_id),
SessionEvent::ReceivedMail { seconds } => {
mail::received_mail(seconds, &mut mail_pending, &mail_open, &net_commands)
}
SessionEvent::NextMailTime { seconds } => {
mail::next_mail_time(seconds, &mut mail_pending)
}
// The player-trade arc (decision 0592 P1): the status packet drives the open/accept/close
// state machine, the extended snapshot replaces one side's item/gold — both into the
// `TradeSession` the trade feed (`crate::ui_trade`) reads.
SessionEvent::TradeStatus { status } => {
trade::trade_status(status, &mut trade_session, &net_commands)
}
SessionEvent::TradeStatusExtended { state } => {
trade::trade_status_extended(&state, &mut trade_session)
}
SessionEvent::WorldStates { scope, states } => {
world::world_states(scope, states, &mut world_states)
}
}
}
// Flush the staged descriptor seeds/deltas onto the entities born this drain (now spawned by the
// above Commands) — one insert each, fully merged, so no partial delta clobbers another.
for (guid, fields) in pending {
if let Some(&e) = index.0.get(&guid) {
commands.entity(e).insert(ObjectStore(fields));
}
}
}
/// Tag our own player's streamed entity with [`SelfPlayer`] once we know our guid — by matching the
/// [`Guid`] component against [`SelfGuid`]. The renderer skips this entity (the controller owns our
/// avatar); the controller reads its transform to take control. Done as its own pass (rather than at
/// spawn) so it's robust to the order our guid and our create packet arrive in.
///
/// The controller's animation motion source (`MovementState`) rides the tag: a cross-map worldport
/// despawns every tracked entity — our avatar included — and the new map re-streams it, so any
/// per-entity state attached only at the one-shot take-control edge is lost on transfer. That was
/// the ".tele to another continent" bug: the re-tagged avatar had no `MovementState`, the anim
/// selector read it as stationary, and it slid around in the Stand pose.
pub(super) fn tag_self_player(
mut commands: Commands,
self_guid: Res<SelfGuid>,
untagged: Query<(Entity, &Guid), Without<SelfPlayer>>,
) {
let Some(me) = self_guid.0 else {
return;
};
for (entity, guid) in &untagged {
if guid.0 == me {
commands
.entity(entity)
.insert((SelfPlayer, crate::creature_anim::MovementState::default()));
}
}
}