Rules
Declarative reactions to Bus topics with per-key where, count, once and cooldown gates, in place of hand-kept counters and timestamps.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Rules - Since
- v0.7.0
- Source
- Rules.luau
Kits and services publish facts on the Bus: Inventory.Granted, your own Combat.Kill, and so on. As
soon as a reaction needs “only every 10th time”, “only once” or “at most once every 5 seconds, per player”, a plain
Bus:subscribe handler grows a counter table, an os.clock() timestamp and an “already fired for this player” set,
none of which is what the rule is about. Rules is that bookkeeping built once. engine:on(topic) starts a chain of gates and
:run(action) arms it.
Mental model
Section titled “Mental model”Rules.attach(kernel, options?) returns an engine. engine:on(topic) returns an unarmed rule, a table that records the
gates you ask for. where, count, once, cooldown and key store settings and return the rule, in any order.
Nothing subscribes until run(action).
run subscribes to the topic on kernel.Bus. Each publish walks a fixed pipeline, whatever order the chain was
written in:
wherepredicates. Each is called throughpcallwith the event’s arguments (the topic is stripped, so aCombat.Killpredicate receives(killerSession, victimSession)). A falsy result or a throw drops the event. Repeatedwherecalls must all pass. An event that passes counts inStats.Matched.key. The counting identity iskey(fn)called with the same arguments, or the first argument when nokeyis set (the session, in every kit’s publish convention). Akeyfunction that throws drops the event, like a throwing predicate. Since 0.9.0; before, the throw escaped into the Bus. A nil key goes into one shared bucket.once. If this key already fired, the event is dropped.cooldown. If this key fired within the lastcooldown(seconds)by the engine’s clock (os.clockby default), the event is dropped.count. The key’s counter goes up; unless it is a multiple ofcount(n), the event is dropped. Because this runs afteronceandcooldown, “every nth” means every nth event that survived them. An event dropped by the cooldown never advances the count.- Fire. The key’s fired flag and timestamp are set,
Stats.Firedgoes up, andtask.spawn(action, ...)runs the action with the event’s arguments.
The counters, fired flags and timestamps live in tables with weak keys (__mode = "k"). When the key is a session or
player object that nothing else references any more, its entries can be collected, so state for departed players does
not pile up. String and number keys are never collected.
task.spawn puts the action on its own thread. An action that throws fails there, and the publisher, the other
subscribers and the rule keep working. A rule is single use: any chain method called after run raises
this rule already ran run(); build a new rule instead.
--!strict-- RewardService: streak bonuses and first-legendary announcements.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local Items = require(ServerScriptService.Game.Config.Items)local Rules = require(ReplicatedStorage.ChloeKernel.Rules)
local RewardService = { Name = "RewardService",}
function RewardService.init(self: any, kernel: any) self.Kernel = kernelend
function RewardService.start(self: any) local Kernel = self.Kernel local Engine = Rules.attach(Kernel) self.Engine = Engine
-- CombatService publishes Combat.Kill(killerSession, victimSession). -- Every 3rd kill per player pays a streak, at most once every 5 seconds. Engine:on("Combat.Kill") :where(function(killer, victim) return killer ~= victim end) :count(3) :cooldown(5) :run(function(killer) Kernel.Bus:publish("Reward.Streak", killer) end)
-- InventoryKit publishes Inventory.Granted(session, itemId, count). -- The first legendary each player gets on this server is announced once. Engine:on("Inventory.Granted") :where(function(_session, itemId: string) local Item = Items[itemId] return Item ~= nil and Item.Rarity == "Legendary" end) :once() :run(function(session, itemId: string) Kernel.Bus:publish("Reward.FirstLegendary", session, itemId) end)end
function RewardService.stop(self: any) if self.Engine then self.Engine:destroy() endend
return RewardServiceBoth rules key on the first argument, the session, so one player’s streak never touches another’s. run returns a
handle with live stats:
local Handle = Engine:on("Combat.Kill"):count(3):run(function(killer) Kernel.Bus:publish("Reward.Streak", killer)end)print(Handle.Stats.Matched, Handle.Stats.Fired) -- kills seen, streaks paidHandle.disconnect() -- unsubscribes and removes the rule from the engine| Member | Description |
|---|---|
Rules.attach(kernel, options?) -> Engine |
options.Clock: (() -> number)? is the clock for cooldown, os.clock by default. |
engine:on(topic: string) -> Rule |
Starts a rule on a Bus topic. Subscribes nothing yet. |
engine:destroy() |
Disconnects every rule this engine armed. |
rule:where(predicate) -> Rule |
Adds a filter over the event’s arguments. A throw counts as false. Repeated calls must all pass. |
rule:count(every: number) -> Rule |
Fires on every everyth surviving event per key. Asserts an integer of at least 1. |
rule:once() -> Rule |
Fires at most once per key for the life of the rule. |
rule:cooldown(seconds: number) -> Rule |
Drops events within seconds of that key’s last fire. Asserts a number above 0. |
rule:key(keyOf) -> Rule |
Sets the counting identity. keyOf(...) receives the event’s arguments; a throw drops the event. Default: the first argument. A nil result shares one bucket. |
rule:run(action) -> Handle |
Arms the rule: subscribes and starts matching. Chain methods called afterwards raise. |
handle.Stats.Matched |
Events that passed every where, including ones a throwing key then dropped. |
handle.Stats.Fired |
Events that ran the action. |
handle.disconnect() |
Unsubscribes from the Bus and removes the handle from its engine. |
Design notes
Section titled “Design notes”- Since 0.9.0
disconnect()also removes the handle from the engine, so an engine that arms and drops many short-lived rules no longer keeps every old handle untildestroy(). - Reach for Rules when a handler would otherwise reimplement one of its gates: a counter checked with
% n, a timestamp compared against a window, a set of players already rewarded. For logic that does not fit those gates (branching, effects that depend on more than matched or not), a directkernel.Bus:subscribe(topic, fn)keeps the whole behavior in one visible function. Foundations.spec.luaucoverswhere, per-keycount,once,cooldownanddisconnect. The core regression runner covers the throwingkeyand the handle removal.