diff --git a/module/src/world.rs b/module/src/world.rs index d378df2cb..319d8461b 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -768,13 +768,10 @@ pub(crate) fn apply_zone_transition( /// /// CROSS map: the client needs a full reload (`SMSG_TRANSFER_PENDING`/`SMSG_NEW_WORLD`, gateway-side), so /// the live entity is DESPAWNED here rather than moved — `build_player_entity` rebuilds it fresh on the -/// far side once the gateway drives `MSG_MOVE_WORLDPORT_ACK` (mirroring `player_login`'s own entity -/// build). Progression is persisted BEFORE the despawn (`persist_entity`, the same discipline -/// `remove_from_world`/logout uses) so nothing earned on the old map is lost; combat is disengaged and -/// stealth broken for the same reason `remove_from_world` does it on logout — an orphan `game_melee_attack` -/// row or a stale `A_STEALTH` aura would otherwise survive the entity's deletion and corrupt the arrival -/// (see `remove_from_world`'s identical rationale). The durable `Character` row is updated in BOTH -/// branches (single source of truth for the WORLDPORT_ACK rebuild). +/// far side once the gateway drives `MSG_MOVE_WORLDPORT_ACK`. Progression is persisted before the +/// despawn so nothing earned on the old map is lost. Combat, stealth, and public motion state are +/// cleared before the entity is deleted, so none survives on the source map. The durable `Character` +/// row is updated in both branches because the WORLDPORT_ACK rebuild reads it. /// /// No-op if the player isn't in world. pub(crate) fn teleport_player( @@ -818,6 +815,7 @@ pub(crate) fn teleport_player( // Combat/visibility cleanup mirrors logout — see the doc comment above. crate::combat::disengage(ctx, player_guid); crate::spell::break_stealth(ctx, player_guid); + ctx.db.game_entity_motion().guid().delete(player_guid); entities.guid().delete(player_guid); } else { let (grid_x, grid_y) = spatial::grid_cell(x, y); diff --git a/module/tests/teleport_motion.rs b/module/tests/teleport_motion.rs new file mode 100644 index 000000000..76b582b47 --- /dev/null +++ b/module/tests/teleport_motion.rs @@ -0,0 +1,102 @@ +mod support; + +use std::time::Duration; + +use support::{poll_until, Standalone}; + +const CHARACTER_GUID: u64 = 1; +const HEARTBEAT: u32 = lyracore_shared::opcodes::movement::MSG_MOVE_HEARTBEAT; + +/// Runs only when requested because it builds and publishes the Wasm module to its own standalone. +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn teleport_motion_rows_follow_the_live_entity_boundary() { + let mut standalone = Standalone::start("teleport-motion"); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call("debug_spawn_player_entity", &[&CHARACTER_GUID.to_string()]); + + move_to(&standalone, 100.0, 100.0, 1); + wait_for_public_motion(&standalone); + + standalone.assert_call( + "debug_teleport", + &[ + &CHARACTER_GUID.to_string(), + "0", + "200.0", + "200.0", + "20.0", + "0.0", + ], + ); + assert_eq!(row_count(&standalone, "game_world_entity"), 1); + assert_eq!( + row_count(&standalone, "game_entity_motion"), + 1, + "a same-map teleport keeps the live entity's public motion row" + ); + + // Stop the isolated node's publisher after it has produced the public row. The next movement + // therefore leaves a known pending row for the teleport boundary to clean up as well. + standalone.assert_sql("DELETE FROM game_motion_publish_schedule"); + move_to(&standalone, 201.0, 200.0, 2); + assert_eq!(row_count(&standalone, "game_entity_motion_pending"), 1); + + standalone.assert_call( + "debug_teleport", + &[ + &CHARACTER_GUID.to_string(), + "1", + "300.0", + "300.0", + "30.0", + "0.0", + ], + ); + assert_eq!(row_count(&standalone, "game_world_entity"), 0); + assert_eq!( + row_count(&standalone, "game_entity_motion"), + 0, + "a cross-map teleport must not leave public motion on the source Shard" + ); + assert_eq!( + row_count(&standalone, "game_entity_motion_pending"), + 0, + "a cross-map teleport must not leave staged motion on the source Shard" + ); +} + +fn move_to(standalone: &Standalone, x: f32, y: f32, move_time_ms: u32) { + let args = [ + CHARACTER_GUID.to_string(), + HEARTBEAT.to_string(), + "[]".to_string(), + x.to_string(), + y.to_string(), + "20.0".to_string(), + "0.0".to_string(), + move_time_ms.to_string(), + ]; + standalone.assert_call( + "gw_movement_update", + &args.iter().map(String::as_str).collect::>(), + ); +} + +fn wait_for_public_motion(standalone: &Standalone) { + assert!( + poll_until(Duration::from_secs(5), || { + row_count(standalone, "game_entity_motion") == 1 + }), + "scheduled publisher did not create the public motion row" + ); +} + +fn row_count(standalone: &Standalone, table: &str) -> usize { + standalone + .query_rows(&format!( + "SELECT * FROM {table} WHERE guid = {CHARACTER_GUID}" + )) + .len() +}