Roblox GuidebookБаза знаний
Русский ⌄

Разработка / ROBLOX

Ограничение запросов в Roblox: защита серверной подсказки от частых нажатий

Делаем учебный ограничитель для запроса подсказки в мастерской: четыре попытки сразу, затем пополнение по две в секунду. Разбираем отдельное состояние игроков, серверное время, поведение кнопки и локальные проверки без настоящей нагрузки на Roblox.

Обновлено:

Выбери конкретное действие для ограничения #

Представь мастерскую, где новичок нажимает «Где станция?» и получает подсказку. Несколько быстрых нажатий могут быть случайностью, попыткой дождаться ответа или нежелательным повтором. Если каждый запрос запускает тяжёлую работу, одна кнопка способна создавать лишнюю нагрузку. Сначала запиши, какая серверная операция происходит после нажатия и почему её частоту нужно ограничить.

Мы используем выдачу текстовой подсказки, а не покупку, награду или сохранение профиля. Это собственный учебный сценарий, не функция, добавленная в игры автора сайта. Параметры «четыре» и «две в секунду» выбраны для объяснения. Они не являются лимитами Roblox или рекомендацией для любого игрового действия. Настоящий многопользовательский нагрузочный тест здесь не выполнялся.

Кнопка клиента не определяет правило сервера #

Можно временно отключить кнопку на устройстве игрока и показать ожидание ответа. Это помогает человеку понять, что запрос уже отправлен. Но сервер всё равно должен самостоятельно решать, разрешён ли новый вызов. Документация Roblox отдельно подчёркивает, что нельзя полагаться только на ограничение частоты на стороне клиента.

Не принимай от клиента число «сколько запросов осталось» или время «когда я нажал в прошлый раз» как основание разрешения. В нашем примере остаток и время хранятся в серверном модуле. Клиент запрашивает подсказку; сервер принимает своё решение. Ограничение не превращает плохие параметры в хорошие: проверка типа, допустимого действия и игрового состояния остаётся отдельной задачей.

Корзина жетонов разрешает короткую серию #

Для каждого игрока воображаем корзину вместимостью четыре жетона. Одна попытка тратит один жетон. Со временем жетоны возвращаются со скоростью два в секунду, но их количество не превышает вместимость. Поэтому сразу можно выполнить четыре попытки, а непрерывная длинная серия должна ждать пополнения. Это схема token bucket, которую Roblox описывает как распространённый подход.

Не путай её с правилом «четыре вызова в каждом календарном отрезке секунды». После короткого периода ожидания может вернуться только часть жетона. Пока нет целого жетона, новая попытка не разрешается. Мы проверяем дробное пополнение, чтобы читатель видел, откуда получается задержка, а не воспринимал её как случайную ошибку кнопки.

Учебная корзина: 4 и 2/сОткрыть изображение крупнее ↗
Вымышленный маршрут локального теста. Время отсчитывается относительно начала упражнения.
ВремяПопыткаРезультат и остаток
0Первая–четвёртаяРазрешены: 4 → 0
0ПятаяОтказ: 0
0.25 sНовая попыткаОтказ: 0,5
0.5 sНовая попыткаРазрешена: 0

У каждого игрока своё состояние #

Если A потратил все четыре жетона, новый запрос B не должен блокироваться из-за A. Поэтому корзины находятся в таблице, где ключ — объект Player, полученный серверным OnServerEvent. Не используй переданное клиентом имя или идентификатор в качестве замены этого объекта. Иначе выбор чужой корзины окажется частью входных данных, которым ты доверяешь.

Отдельные корзины не ограничивают суммарную нагрузку всех игроков. Много пользователей могут одновременно исчерпать свои разрешённые попытки. Если действие дорогое или обращается к backend, нужен отдельный анализ общей частоты, очереди и стоимости операции. В этом модуле нет общего бюджета сервера, ограничения параллельных задач или распределённого состояния нескольких серверов.

Как устроен учебный ModuleScript #

Помести HintRequestLimiter в ServerScriptService. Конструктор new(capacity, refillPerSecond, clock) задаёт вместимость, скорость и серверную функцию часов. Allow(player) возвращает разрешение и короткий код результата. Forget(player) удаляет локальное состояние после ухода. Модуль не обращается к DataStore, не создаёт RemoteEvent и не выдаёт подсказку самостоятельно.

Вместимость должна быть целой от 1 до 1000, скорость — положительным конечным числом не выше 1000. Это выбранные ограничения нашего примера, а не платформенные квоты. Дробная скорость допустима. Ошибка или некорректное число часов приводит к отказу; при движении часов назад модуль не добавляет жетоны. Сам ограничитель использует только локальное состояние текущего сервера.

os.clock не имеет заданной абсолютной начальной точки. Сравнивай разницу значений, а не календарную дату. Учебный модуль допускает конечное отрицательное начало; отдельный локальный тест проверяет такое пополнение.

-- Original teaching limiter for server-side hint requests.
-- Pass the Player supplied by OnServerEvent, never a client-supplied identity.
-- Frequency control does not validate permissions or grant rewards.
local Limiter = {}

local function finite(value)
    return type(value) == "number" and value == value
        and value > -math.huge and value < math.huge
end

function Limiter.new(capacity, refillPerSecond, clock)
    assert(finite(capacity) and capacity == math.floor(capacity)
        and capacity >= 1 and capacity <= 1000, "Invalid capacity")
    assert(finite(refillPerSecond) and refillPerSecond > 0
        and refillPerSecond <= 1000, "Invalid refill rate")
    if clock == nil then clock = os.clock end
    assert(type(clock) == "function", "Invalid clock")
    local buckets = {}
    local adapter = {}

    function adapter:Allow(player)
        if player == nil then return false, "MissingPlayer" end
        local ok, now = pcall(clock)
        if not ok or not finite(now) then
            return false, "InvalidClock"
        end
        local bucket = buckets[player]
        if not bucket then
            bucket = {tokens = capacity, last = now}
            buckets[player] = bucket
        elseif now < bucket.last then
            return false, "ClockWentBackwards"
        else
            bucket.tokens = math.min(capacity,
                bucket.tokens + (now - bucket.last) * refillPerSecond)
            bucket.last = now
        end
        if bucket.tokens < 1 then return false, "RateLimited" end
        bucket.tokens -= 1
        return true, "Allowed"
    end

    function adapter:Forget(player)
        buckets[player] = nil
    end
    return adapter
end

return Limiter

Подключи серверный обработчик осмысленно #

Для примера создай RemoteEvent GetHint в ReplicatedStorage и Script сервера рядом с модулем. Script получает сервисы, подключает модуль через require и создаёт limiter с параметрами 4, 2 и os.clock. Обработчик использует Player, который передаёт Roblox, затем проверяет запрос и отправляет только заранее определённый текст подсказки этому игроку.

Пример ожидает атрибут TutorialStep со значением 1, назначенный существующей серверной логикой обучения. Он сам не начинает обучение и не меняет этот атрибут по запросу клиента. Если такого состояния нет, подсказка не отправится. Клиентский интерфейс тоже не включён в пример: его нужно отдельно подключить к GetHint и обработке ответа. Серверный Script показан для интеграции, а не как проверенная готовая игра.

Путь серверной подсказкиОткрыть изображение крупнее ↗
Оригинальная схема обработчика: жетон не заменяет проверку параметров и состояния.
-- Server Script; create ReplicatedStorage.GetHint as a RemoteEvent first.
-- TutorialStep must be maintained by your existing SERVER gameplay logic.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Limiter = require(
    game:GetService("ServerScriptService"):WaitForChild("HintRequestLimiter")
)
local request = ReplicatedStorage:WaitForChild("GetHint")
assert(request:IsA("RemoteEvent"), "GetHint must be a RemoteEvent")
local limiter = Limiter.new(4, 2, os.clock)

request.OnServerEvent:Connect(function(player, hintName)
    -- Each attempt consumes quota before further processing.
    if not limiter:Allow(player) then return end
    if type(hintName) ~= "string" or #hintName > 32 then return end
    if hintName ~= "FindWorkshop" then return end
    if player:GetAttribute("TutorialStep") ~= 1 then return end
    local character = player.Character
    local humanoid = character and character:FindFirstChildOfClass("Humanoid")
    if not humanoid or humanoid.Health <= 0 then return end
    request:FireClient(player, "Hint", "Look for the workshop sign.")
end)

Players.PlayerRemoving:Connect(function(player)
    limiter:Forget(player)
end)

Реши, что именно расходует попытку #

В нашем обработчике Allow вызывается до проверки строки, поэтому попытка расходует жетон даже при неверном имени подсказки или неподходящем этапе обучения. Это сознательное правило учебного примера. Его удобно объяснить так: ограничивается число обращений к обработчику, а не только число успешно выданных подсказок. Некорректный ввод не получает бесплатный бесконечный путь к последующим проверкам.

Не возвращай потраченный жетон автоматически после каждого отказа, иначе поток неправильных запросов может обходить выбранное правило. При этом игровой смысл действия всё равно должен проверяться отдельно. Наличие жетона не означает доступ к награде, владение предметом, завершение задания или право изменять чужой объект. Для другой задачи порядок и стоимость попытки нужно явно пересмотреть.

Не отвечай на каждый отвергнутый повтор #

Учебный обработчик молча прекращает ограниченный запрос. Если вместо этого рассылать ответ или писать подробную строку журнала при каждом отказе, поток повторов может создавать новую работу уже на пути отказа. Не делай из ограничителя источник бесконечных уведомлений. Для наблюдения лучше заранее выбрать ограниченный способ сводить количество отказов, а не сохранять любой входной пакет.

Молчаливый отказ означает, что интерфейс должен уметь вернуть кнопку в рабочее состояние по разумному локальному ожиданию и объяснить пользователю повторную попытку. В этой статье клиентский таймер не реализован. Не оставляй кнопку навсегда отключённой в ожидании ответа, который сервер намеренно не отправляет. Разделяй удобство интерфейса и решение сервера о разрешении.

Проверка с управляемыми часами #

Локальный Luau-тест передаёт в конструктор собственную функцию часов. В момент 0 игрок A делает четыре разрешённые попытки; пятая получает RateLimited. В момент 0,25 накоплено полжетона, поэтому новая попытка ещё запрещена. В момент 0,5 доступен один жетон: одна попытка разрешается, следующая — нет. Числа описывают тест, а не посещения реальной игры.

Параллельно в том же локальном тесте проверяется независимая корзина B. После большого простоя A получает не больше четырёх попыток. Некорректные часы и движение назад не создают запас. Мы проверили ошибки настройки, очистку и дробную скорость пополнения. Эти проверки выполнялись с подставными объектами и временем, без RemoteEvent Roblox, настоящих игроков и измерения производительности сервера.

ПроверкаОжидание
Пятая попытка сразуRateLimited
Запрос другого игрокаНезависимая корзина
Длинный простойНе более 4 жетонов
Часы идут назадНет пополнения
Некорректные часыInvalidClock
Состояние после очисткиНовая полная корзина

Уход игрока требует очистки #

Подключи Players.PlayerRemoving к limiter:Forget(player). Иначе таблица продолжит удерживать состояние ушедших игроков до завершения сервера. Не вызывай Forget при обычном отказе: это создаст новую полную корзину на следующей попытке и отменит смысл ограничения. Очистка — часть жизненного цикла игрока, а не способ вернуть кнопке разрешение.

Новый вход после удаления состояния начинает новую корзину. Это ожидаемое поведение локального примера, но не защита от повторных подключений и не глобальное ограничение аккаунта. Состояние не сохраняется между серверами. Для задачи, которая требует учитывать разные сеансы, нужно отдельное проектирование; не обещай такую гарантию на основании очистки таблицы.

Ограничение частоты не ограничивает всю работу #

После разрешённого вызова другая функция может начать длительную задачу. Наш модуль не ждёт её завершения и не контролирует число уже запущенных задач. Даже допустимая частота может быть слишком высокой для тяжёлого действия. Посмотри отдельно, что происходит после проверки: создаются ли модели, запускаются ли запросы к API, затрагиваются ли другие игроки.

Для обработки покупок, сохранения данных или общей экономики нужны правила соответствующих систем, а не только жетоны. Также этот пример не проверяет расстояние до физической станции: подсказка специально является текстовым действием без такого требования. Если переносишь ограничитель на взаимодействие с предметом, добавь необходимые серверные проверки позиции, прав и состояния. Мы не меняли код существующих игр автора.

Что передать чату разработки #

Сохрани действие, которое ограничиваешь, смысл расхода попытки, вместимость, скорость и список серверных проверок. Добавь маршруты локального теста и отдельную пометку о том, что реальный сетевой и нагрузочный тест ещё нужен. Не передавай число четыре как универсальную настройку для любых кнопок или оружия: оно связано только с этим упражнением.

Попроси разработчика проверить поведение двух игроков, длинного простоя, ухода и неподходящего запроса, а также возврат кнопки после молчаливого отказа. Отдельно перечисли не реализованные задачи: общий бюджет сервера, параллельные операции, разные сеансы и клиентский интерфейс. Такой список позволяет использовать идею в следующей игре, сохраняя понятные границы готовности.

Первоисточники

Roblox Creator Hub — Client-server boundary
Luau — Standard library
Roblox Creator Hub — os