From 8f855105b96a662ad2105de4decc08d40de92a78 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Sat, 4 Jul 2026 03:33:17 +0300 Subject: [PATCH] Cats on bridges, no more infinite familiar call loops --- .../global/skill/construction/HouseZone.java | 8 ++++ .../skill/summoning/familiar/Familiar.java | 43 +++++++++++++++++-- .../main/core/game/world/map/RegionManager.kt | 8 +++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/Server/src/main/content/global/skill/construction/HouseZone.java b/Server/src/main/content/global/skill/construction/HouseZone.java index 31a57f6f8..992640811 100644 --- a/Server/src/main/content/global/skill/construction/HouseZone.java +++ b/Server/src/main/content/global/skill/construction/HouseZone.java @@ -115,6 +115,14 @@ public final class HouseZone extends MapZone { return true; } } + + // Take the player's familiar along before the teardown pulse below wipes every NPC + // still left inside the house - a wiped familiar is gone until relog (GL #2313). The + // call either materializes it at the destination or parks it invisibly at the owner's + // side until there is room for it. + if (!logout && p.getFamiliarManager().hasFamiliar()) { + p.getFamiliarManager().getFamiliar().call(); + } remove_items(p); // The below tears down the house if the owner was the one who left diff --git a/Server/src/main/content/global/skill/summoning/familiar/Familiar.java b/Server/src/main/content/global/skill/summoning/familiar/Familiar.java index b561a5b30..c4f43cdef 100644 --- a/Server/src/main/content/global/skill/summoning/familiar/Familiar.java +++ b/Server/src/main/content/global/skill/summoning/familiar/Familiar.java @@ -391,10 +391,17 @@ public abstract class Familiar extends NPC implements Plugin { return false; } - + @Override public void onRegionInactivity() { call(); + // NPC.tick() short-circuits into this method while the region is inactive, so Entity.tick() + // never runs to consume a teleport that call() just set; apply it manually here or the + // familiar re-calls forever without ever moving (GL #2313). + if (getProperties().getTeleportLocation() != null && owner.isActive()) { + getWalkingQueue().update(); + getUpdateMasks().prepare(this); + } } @Override @@ -699,10 +706,20 @@ public abstract class Familiar extends NPC implements Plugin { //owner.getPacketDispatch().sendMessage("Your familiar is too big to fit here. Try calling it again when you are standing"); //owner.getPacketDispatch().sendMessage("somewhere with more space."); //spamTimer = 50; + // No room to materialize, so wait invisibly at the owner's side (like init() does when + // there is no room at summon time). Waiting in place instead means being left behind: + // once the abandoned region unloads, the familiar either gets wiped with it + // (DynamicRegion teardown) or gets stuck endlessly re-calling from an inactive region + // that no longer ticks it normally (GL #2313). + setInvisible(true); + stayWithOwner(); return false; } setInvisible(owner.getZoneMonitor().isRestricted(ZoneRestriction.FOLLOWERS) && !owner.getLocks().isLocked("enable_summoning")); - if (isInvisible()) return true; + if (isInvisible()) { + stayWithOwner(); + return true; + } getProperties().setTeleportLocation(destination); if (!(this instanceof Pet)) { if (firstCall) { @@ -723,13 +740,33 @@ public abstract class Familiar extends NPC implements Plugin { } else { face(owner); } - if (!isRenderable() && owner.isActive()) { + // A familiar pulled in from another region must move immediately rather than on its next + // tick: NPCs tick before players, so when this call runs from the owner's movement (e.g. + // leaving a POH), the familiar's tick for this cycle has already passed - and a region + // teardown pulse (POH exit portal, building-mode rebuild) runs at the start of the next + // cycle, wiping any NPC still inside before it can consume a pending teleport (GL #2313). + boolean leftInAnotherRegion = getLocation().getRegionId() != owner.getLocation().getRegionId(); + if ((!isRenderable() || leftInAnotherRegion) && owner.isActive()) { // log(this.getClass(), Log.ERR, "Familiar in inactive region!"); getWalkingQueue().update(); getUpdateMasks().prepare(this); } return true; } + + /** + * Moves a familiar that cannot materialize to its owner's location, so it is never left + * behind in a region that may unload. The teleport is applied immediately because this can + * run from an inactive region, where Entity.tick() (the normal consumer) no longer runs. + */ + private void stayWithOwner() { + if (!owner.isActive() || getLocation().equals(owner.getLocation())) { + return; + } + getProperties().setTeleportLocation(owner.getLocation()); + getWalkingQueue().update(); + getUpdateMasks().prepare(this); + } /** * Gets the spawning location of the familiar. diff --git a/Server/src/main/core/game/world/map/RegionManager.kt b/Server/src/main/core/game/world/map/RegionManager.kt index 9fb7b848b..309c582a9 100644 --- a/Server/src/main/core/game/world/map/RegionManager.kt +++ b/Server/src/main/core/game/world/map/RegionManager.kt @@ -332,10 +332,14 @@ object RegionManager { } } val l = owner.location.transform(stepX, stepY, 0) - // Check if ALL target tiles are unclipped + // Check if the NPC can stand on ALL target tiles. This must not use isClipped(), + // which rejects any tile that has a wall along ANY of its sides: every tile of a + // railed bridge deck has one (e.g. the whole Lumbridge bridge), which made familiars + // and pets unable to materialize anywhere on it (GL #1471). Materializing is the + // same question as teleporting, so ask the teleport predicate. for (x in 0 until node.size()) { for (y in 0 until node.size()) { - if (isClipped(l.transform(x, y, 0))) { + if (!isTeleportPermitted(l.transform(x, y, 0))) { continue@outer } }