Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions module/src/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
102 changes: 102 additions & 0 deletions module/tests/teleport_motion.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>(),
);
}

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()
}