Skip to content

fix(teleport): prevent recursive teleport loops - #3818

Merged
dudantas merged 18 commits into
mainfrom
dudantas/fix-teleport-loop
Jun 9, 2026
Merged

fix(teleport): prevent recursive teleport loops#3818
dudantas merged 18 commits into
mainfrom
dudantas/fix-teleport-loop

Conversation

@dudantas

@dudantas dudantas commented Jan 26, 2026

Copy link
Copy Markdown
Member

Resolves #3585

Summary by CodeRabbit

  • Bug Fixes

    • Detects and blocks recursive teleport attempts, aborting to prevent infinite loops and unintended behavior.
    • Teleport processing now uses a scoped guard to ensure teleport state is always cleaned up on exit, including on early returns.
    • Improved, per-entity rate-limited logging for recursion events with clearer context (creature vs. item) and exponential backoff.
  • Chores

    • Updated build manifest baseline.

✏️ Tip: You can customize this high-level summary in your review settings.

TalkAction for tests:

local teleportLoopTest = TalkAction("/tploop")

local activeTeleportLoopTests = {}
local nextTeleportLoopToken = 0

local DEFAULT_FAST_DELAY = 50
local DEFAULT_SLOW_DELAY = 150
local DEFAULT_SPAM_COUNT = 40
local DEFAULT_SUSTAINED_COUNT = 100
local DEFAULT_BURST_COUNT = 25
local DEFAULT_SAME_COUNT = 8
local MAX_TEST_COUNT = 200
local MIN_DELAY = 1
local MAX_DELAY = 5000

local function clamp(value, minValue, maxValue)
	if value < minValue then
		return minValue
	end

	if value > maxValue then
		return maxValue
	end

	return value
end

local function samePosition(left, right)
	return left.x == right.x and left.y == right.y and left.z == right.z
end

local function positionKey(position)
	return string.format("%d:%d:%d", position.x, position.y, position.z)
end

local function buildPosition(origin, offsetX, offsetY)
	return Position(origin.x + offsetX, origin.y + offsetY, origin.z)
end

local function collectTestPositions(player)
	local origin = player:getPosition()
	local offsets = {
		{ 1, 0 },
		{ -1, 0 },
		{ 0, 1 },
		{ 0, -1 },
		{ 1, 1 },
		{ -1, 1 },
		{ 1, -1 },
		{ -1, -1 },
		{ 2, 0 },
		{ -2, 0 },
		{ 0, 2 },
		{ 0, -2 },
	}

	local positions = {}
	local seen = {}
	for _, offset in ipairs(offsets) do
		local target = buildPosition(origin, offset[1], offset[2])
		local closest = player:getClosestFreePosition(target, false)
		if closest and closest.x ~= 0 and not samePosition(closest, origin) then
			local key = positionKey(closest)
			if not seen[key] then
				positions[#positions + 1] = Position(closest.x, closest.y, closest.z)
				seen[key] = true
			end
		end
	end

	return positions
end

local function sendUsage(player)
	player:sendTextMessage(MESSAGE_EVENT_ADVANCE, "Usage: /tploop <slow|spam|sustain|burst|fanout|same|push|all|stop>[, count[, delayMs]]")
	player:sendTextMessage(MESSAGE_EVENT_ADVANCE, "Examples: /tploop sustain,100,1 | /tploop spam,40,50 | /tploop burst,25 | /tploop fanout,25,10")
end

local function parseParams(param)
	local trimmed = param:trim()
	if trimmed == "" then
		return "help", nil, nil
	end

	local parts = {}
	if trimmed:find(",", 1, true) then
		for part in trimmed:gmatch("([^,]+)") do
			parts[#parts + 1] = part:trim()
		end
	else
		for part in trimmed:gmatch("%S+") do
			parts[#parts + 1] = part:trim()
		end
	end

	local scenario = (parts[1] or "help"):lower()
	local count = tonumber(parts[2] or "")
	local delay = tonumber(parts[3] or "")
	return scenario, count, delay
end

local function startTest(player)
	nextTeleportLoopToken = nextTeleportLoopToken + 1
	activeTeleportLoopTests[player:getId()] = nextTeleportLoopToken
	return nextTeleportLoopToken
end

local function getActivePlayer(playerId, token)
	if activeTeleportLoopTests[playerId] ~= token then
		return nil
	end

	return Player(playerId)
end

local function finishTest(playerId, token, label, successCount, failedCount, keepActive)
	local player = getActivePlayer(playerId, token)
	if not player then
		return
	end

	local message = string.format("[tploop:%s] finished. success=%d failed=%d", label, successCount, failedCount)
	player:sendTextMessage(MESSAGE_EVENT_ADVANCE, message)
	logger.info("[tploop:{}] Player {} finished teleport loop test. success={}, failed={}", label, player:getName(), successCount, failedCount)

	if not keepActive and activeTeleportLoopTests[playerId] == token then
		activeTeleportLoopTests[playerId] = nil
	end
end

local function teleportAndCount(player, position, pushMovement, counters)
	if player:teleportTo(position, pushMovement) then
		counters.success = counters.success + 1
	else
		counters.failed = counters.failed + 1
	end
end

local function runBurst(player, token, positions, count, pushMovement, keepActive, label)
	local playerId = player:getId()
	local counters = { success = 0, failed = 0 }

	for index = 1, count do
		local activePlayer = getActivePlayer(playerId, token)
		if not activePlayer then
			return
		end

		local position = positions[((index - 1) % #positions) + 1]
		teleportAndCount(activePlayer, position, pushMovement, counters)
	end

	finishTest(playerId, token, label or "burst", counters.success, counters.failed, keepActive)
end

local function runScheduledLoop(player, token, positions, count, delay, pushMovement, keepActive, label)
	local playerId = player:getId()
	local counters = { success = 0, failed = 0 }

	local function step(index)
		local activePlayer = getActivePlayer(playerId, token)
		if not activePlayer then
			return
		end

		local position = positions[((index - 1) % #positions) + 1]
		teleportAndCount(activePlayer, position, pushMovement, counters)

		if index >= count then
			finishTest(playerId, token, label, counters.success, counters.failed, keepActive)
			return
		end

		addEvent(step, delay, index + 1)
	end

	addEvent(step, delay, 1)
end

local function runFanout(player, token, positions, count, delay, keepActive)
	local playerId = player:getId()
	local counters = { success = 0, failed = 0, completed = 0 }

	for index = 1, count do
		addEvent(function(stepIndex)
			local activePlayer = getActivePlayer(playerId, token)
			if not activePlayer then
				return
			end

			local position = positions[((stepIndex - 1) % #positions) + 1]
			teleportAndCount(activePlayer, position, false, counters)
			counters.completed = counters.completed + 1

			if counters.completed >= count then
				finishTest(playerId, token, "fanout", counters.success, counters.failed, keepActive)
			end
		end, delay, index)
	end
end

local function runSamePosition(player, token, count, keepActive)
	local playerId = player:getId()
	local counters = { success = 0, failed = 0 }

	for _ = 1, count do
		local activePlayer = getActivePlayer(playerId, token)
		if not activePlayer then
			return
		end

		teleportAndCount(activePlayer, activePlayer:getPosition(), false, counters)
	end

	finishTest(playerId, token, "same", counters.success, counters.failed, keepActive)
end

local function queueAllScenario(player, token, positions, count, delay)
	local playerId = player:getId()
	local offset = 0
	local slowCount = math.min(count, 8)
	local burstCount = math.min(count, DEFAULT_BURST_COUNT)
	local sameCount = math.min(count, DEFAULT_SAME_COUNT)

	local function queue(label, wait, callback)
		offset = offset + wait
		addEvent(function()
			local activePlayer = getActivePlayer(playerId, token)
			if not activePlayer then
				return
			end

			activePlayer:sendTextMessage(MESSAGE_EVENT_ADVANCE, "[tploop:all] starting " .. label)
			callback(activePlayer)
		end, offset)
	end

	queue("slow", 1, function(activePlayer)
		runScheduledLoop(activePlayer, token, positions, slowCount, DEFAULT_SLOW_DELAY, false, true, "slow")
	end)

	queue("burst", slowCount * DEFAULT_SLOW_DELAY + 1000, function(activePlayer)
		runBurst(activePlayer, token, positions, burstCount, false, true, "burst")
	end)

	queue("spam", 1500, function(activePlayer)
		runScheduledLoop(activePlayer, token, positions, count, delay, false, true, "spam")
	end)

	queue("fanout", count * delay + 1500, function(activePlayer)
		runFanout(activePlayer, token, positions, burstCount, 10, true)
	end)

	queue("push", 1500, function(activePlayer)
		runScheduledLoop(activePlayer, token, positions, burstCount, delay, true, true, "push")
	end)

	queue("same", burstCount * delay + 1500, function(activePlayer)
		runSamePosition(activePlayer, token, sameCount, true)
	end)

	addEvent(function()
		local activePlayer = getActivePlayer(playerId, token)
		if not activePlayer then
			return
		end

		activeTeleportLoopTests[playerId] = nil
		activePlayer:sendTextMessage(MESSAGE_EVENT_ADVANCE, "[tploop:all] finished all queued scenarios.")
	end, offset + 1000)
end

function teleportLoopTest.onSay(player, words, param)
	local scenario, countParam, delayParam = parseParams(param)
	if scenario == "help" then
		sendUsage(player)
		return true
	end

	if scenario == "stop" then
		activeTeleportLoopTests[player:getId()] = nil
		player:sendTextMessage(MESSAGE_EVENT_ADVANCE, "[tploop] stopped pending teleport loop tests for your character.")
		return true
	end

	local positions = collectTestPositions(player)
	if #positions < 2 then
		player:sendCancelMessage("[tploop] Stand in an open area with at least two nearby free tiles.")
		return true
	end

	local count = clamp(math.floor(countParam or DEFAULT_SPAM_COUNT), 1, MAX_TEST_COUNT)
	local delay = clamp(math.floor(delayParam or DEFAULT_FAST_DELAY), MIN_DELAY, MAX_DELAY)
	local token = startTest(player)

	if scenario == "slow" then
		runScheduledLoop(player, token, positions, math.min(count, 20), math.max(delay, DEFAULT_SLOW_DELAY), false, false, "slow")
	elseif scenario == "spam" then
		runScheduledLoop(player, token, positions, count, delay, false, false, "spam")
	elseif scenario == "sustain" or scenario == "sustained" then
		runScheduledLoop(player, token, positions, clamp(math.floor(countParam or DEFAULT_SUSTAINED_COUNT), 1, MAX_TEST_COUNT), delay, false, false, "sustain")
	elseif scenario == "burst" then
		runBurst(player, token, positions, clamp(math.floor(countParam or DEFAULT_BURST_COUNT), 1, MAX_TEST_COUNT), false, false, "burst")
	elseif scenario == "fanout" then
		runFanout(player, token, positions, clamp(math.floor(countParam or DEFAULT_BURST_COUNT), 1, MAX_TEST_COUNT), delay, false)
	elseif scenario == "same" then
		runSamePosition(player, token, clamp(math.floor(countParam or DEFAULT_SAME_COUNT), 1, MAX_TEST_COUNT), false)
	elseif scenario == "push" then
		runScheduledLoop(player, token, positions, count, delay, true, false, "push")
	elseif scenario == "all" then
		queueAllScenario(player, token, positions, count, delay)
	else
		activeTeleportLoopTests[player:getId()] = nil
		sendUsage(player)
	end

	return true
end

teleportLoopTest:separator(" ")
teleportLoopTest:groupType("god")
teleportLoopTest:register()

@coderabbitai

This comment was marked as outdated.

@dudantas
dudantas force-pushed the dudantas/fix-teleport-loop branch from adfacc8 to 454ed06 Compare January 26, 2026 15:32
@dudantas
dudantas force-pushed the dudantas/fix-teleport-loop branch from 454ed06 to 81bb4b6 Compare January 26, 2026 17:48

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/game/game.cpp`:
- Around line 2930-2951: Remove the duplicate recursion check by deleting the
initial if (teleportStack.contains(teleportThing)) block and rely solely on the
insert() result: call teleportStack.insert(teleportThing) and check the inserted
flag (it->second / inserted) to detect recursion; if insertion fails, log the
same detailed messages using thing->getCreature(), thing->getItem(), g_logger()
and __FUNCTION__ and return RETURNVALUE_NOTPOSSIBLE. Ensure teleportThing,
teleportStack and the existing logging branches remain unchanged so behavior and
messages are identical when recursion is detected.

Comment thread src/game/game.cpp Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/game/game.cpp`:
- Around line 2935-2951: The code has a duplicate declaration of
teleportStackGuard which causes a build error; remove the second declaration and
keep a single unique_ptr named teleportStackGuard constructed with teleportThing
and the InternalGame::TeleportStackCleaner deleter (references:
teleportStackGuard, teleportThing, InternalGame::TeleportStackCleaner) so only
one guard instance exists in the scope.

Comment thread src/game/game.cpp Outdated
@dudantas
dudantas force-pushed the dudantas/fix-teleport-loop branch from 9052ef1 to d72291e Compare January 26, 2026 21:56
@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/game/game.cpp`:
- Around line 2934-2944: The current teleport recursion guard
(InternalGame::tryInsertTeleportStack) only blocks re-entrancy in the same stack
but allows infinite loops via event-scheduled teleports; add a per-Thing
cooldown or rate-limiter (for example a lastTeleportTime map keyed by Thing
pointer/id handled in InternalGame or Game class) and check/update it at the
start of the teleport routine (before calling tryInsertTeleportStack) to reject
teleports that occur inside the cooldown window, and remove the duplicate
teleportStackGuard declaration (the redundant teleportStackGuard symbol present
near the teleport logic) so only the single intended guard remains; ensure you
reference and update the same Thing identity used by getCreature()/getItem()
checks when applying cooldown.

Comment thread src/game/game.cpp
@dudantas
dudantas force-pushed the dudantas/fix-teleport-loop branch from 5491a39 to 7d2f20a Compare January 27, 2026 14:17
@github-actions

Copy link
Copy Markdown
Contributor

✅ Updated vcpkg baseline to 2026.01.16 (23dc124705fcac41cf35c33dd9541f5094a9c19f)

Copilot AI review requested due to automatic review settings January 29, 2026 14:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/game/game.cpp Outdated
Comment thread src/game/game.cpp Outdated
Comment thread src/game/game.cpp Outdated
Comment thread src/game/game.cpp Outdated
@github-actions

Copy link
Copy Markdown
Contributor

✅ Updated vcpkg baseline to 2026.01.16 (23dc124705fcac41cf35c33dd9541f5094a9c19f)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@vcpkg.json`:
- Line 35: The "builtin-baseline" value currently set to commit hash
23dc124705fcac41cf35c33dd9541f5094a9c19f is invalid; update the vcpkg baseline
by replacing the "builtin-baseline" value with a valid commit hash from the
official microsoft/vcpkg repository (e.g., pick a known good upstream commit SHA
or the recommended baseline for your vcpkg version), ensuring the key
"builtin-baseline" in vcpkg.json is updated to that valid commit so dependency
resolution succeeds.

Comment thread vcpkg.json Outdated
@github-actions

Copy link
Copy Markdown
Contributor

✅ Updated vcpkg baseline to 2026.01.16 (66c0373dc7fca549e5803087b9487edfe3aca0a1)

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Maintainability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@github-actions

Copy link
Copy Markdown
Contributor

This PR is stale because it has been open 45 days with no activity.

@github-actions github-actions Bot added the Stale No activity label Mar 16, 2026
@dudantas
dudantas marked this pull request as draft May 7, 2026 17:58
@github-actions github-actions Bot removed the Stale No activity label May 8, 2026
@dudantas
dudantas marked this pull request as ready for review June 9, 2026 18:33
@sonarqubecloud

sonarqubecloud Bot commented Jun 9, 2026

Copy link
Copy Markdown

@dudantas dudantas changed the title fix: teleport loop fix(teleport): prevent recursive teleport loops Jun 9, 2026
@dudantas
dudantas merged commit a8e2cea into main Jun 9, 2026
20 checks passed
@dudantas
dudantas deleted the dudantas/fix-teleport-loop branch June 9, 2026 22:34
nicollassantos added a commit to nicollassantos/canary that referenced this pull request Jun 10, 2026
Merges 4 upstream commits from main:
- fix(container) opentibiabr#3995: page-index bounds clamp in sendBatchUpdateContainer,
  isNearDepotBox() → const, shouldCloseContainer depot proximity fix
- fix(teleport) opentibiabr#3818: anti-recursive teleport guard in InternalGame namespace
- feat: release workflow + MyAAC client 1501→1511
- chore: release metadata 3.5.0→3.6.0

SOLID boundary preserved:
- Page-index fix ported to PlayerStashComponent::sendBatchUpdateContainer
  (not re-inlined in player.cpp)
- isNearDepotBox() const propagated through PlayerStashComponent
- Teleport guard added to Game::internalTeleport facade; actual teleport
  logic stays in MovementService::internalTeleport
- 3 regression tests added (isNearDepotBox const, sendBatchUpdate null guard)

Tests: 579/579 passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
nicollassantos added a commit to nicollassantos/canary that referenced this pull request Jun 10, 2026
Applies SOLID/Hexagonal to the anti-recursive teleport fix (main opentibiabr#3818).

Before: ~200 lines of recursion stack + rate limiter + state tracking
embedded as free functions in InternalGame namespace inside game.cpp.

After: TeleportGuard class in src/game/movement/teleport_guard.{hpp,cpp}
with a clear public API:
- tryEnterStack()   — RAII recursion guard (thread_local stack)
- shouldBlockRate() — per-entity burst/sustained rate limiter
- recordBlock()     — exponential-backoff log snapshot
- logBlock()        — structured error output
- reset()           — test isolation hook

Game::internalTeleport keeps the guard pre-check at the facade layer
and delegates actual teleport logic to MovementService (unchanged).

12 TDD unit tests cover: recursion detection, RAII cleanup, burst limit,
rate-window reset, per-key isolation, log suppression and re-emission.

Tests: 591/591 passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

crash because recursive/spam teleports loops

4 participants