CityRP Documentation Repository

Hooks: exposing extension points

Last updated

A system that other code should be able to change or observe without patching it exposes extension points: a veto hook before an action, which decides whether a player may take it, and a lifecycle hook after it, which announces that it happened. The terms are defined in the glossary, and the shape (one hook per action, paired before and after) is recorded in ADR 0003.

Ship a system's extension points with the system. Retrofitting them later means finding every route to the action first, and the routes nobody found are where listeners silently miss events (#2199).

This page is for new extension points. The hooks the gamemode already fires predate it and keep their names and shapes; convert one when you are already editing its system, not as a sweep.

Veto hooks

The default rule is a GM: method

The system's own answer lives in a GM: method named after the hook, and the system asks through hook.Run. GMod runs every hook.Add callback first and only reaches the GM: method when they all abstain (footgun #3), which is what makes the default rule overridable. A rule written inline in the plugin with a hook.Run in front of it can only be overridden after the fact, by a callback that has to restate it.

  • Name: Can<Verb><Object>, specific enough not to collide (CanEditRoomWallpaper, not CanEdit). No plugin prefix.
  • One veto hook per distinct permission rule. When one rule governs several actions (editing covers creating, updating and removing a room), it is one hook. Three hooks with identical defaults let an addon override one and leave the other two open.
  • Where: in the plugin's own realm file (sv_init.lua, or sh_init.lua for a shared hook), so a plugin that does not load takes its default and its callers with it. GM is only set while the gamemode loads, so open the block with local GM = GM or GAMEMODE to survive a hot reload of the file.
  • Returns: always a boolean, plus a player-facing reason string when it refuses. Because the default always answers, nil never reaches the caller.
  • Answers only. A veto hook or default that changes state does so even when a later callback refuses, and runs again for every predicted or repeated check. React to the action on its lifecycle hook, which fires once it is applied.
local GM = GM or GAMEMODE

--- Whether a player may pin a notice to a board. Staff and the kill switch are decided before this
--- runs, by the caller.
--- @hook
--- @param ply Player The player pinning the notice.
--- @param board Entity The board being pinned to.
--- @return boolean allowed Whether the player may pin to the board.
--- @return string? reason Shown to the player when refused.
function GM:CanPinNotice(ply, board)
	if board:CPPIGetOwner() ~= ply then
		return false, "You can only pin notices to your own board."
	end

	return true
end

A hook.Add callback returns nil to abstain, false, reason to refuse, or true to allow. Any non-nil return is final.

Guards run before the hook

A guard is a check that must hold whatever any addon says, so it runs in the caller before hook.Run, never inside the default rule, where a callback's answer skips it. For a player-permission hook, the caller evaluates in this order:

  1. Invalid input: an invalid player or subject is refused.
  2. Staff: allowed. No addon can lock staff out of a system they have to be able to fix.
  3. Kill switch: if the system has one and it is on, refused.
  4. The hook, which reaches the default rule when every callback abstains.

Staff come before the kill switch so they can still act while it is on. A system leaves the staff guard out only when its spec says why.

--- @return boolean allowed
--- @return string? reason
function PLUGIN:CanPin(ply, board)
	if not IsValid(ply) or not IsValid(board) then return false, "That board no longer exists." end
	if ply:IsAdmin() then return true end
	if self.killSwitch:GetBool() then return false, "Notice boards are closed right now." end

	return hook.Run("CanPinNotice", ply, board)
end

The caller reads both values, local ok, reason = self:CanPin(ply, board), and refuses on not ok.

Realm

The server is authoritative: it runs the guards and the hook for every action, whatever a client decided. A veto hook may also run clientside, to predict an action or grey out UI, the way stock PlayerNoClip answers on the client to predict movement. That client answer is a prediction, so the server re-checks.

A hook can be shared only when every input to its guards and default rule is networked. Put the default and the guards in sh_init.lua, and keep callbacks giving the same answer on both realms. A default that reads server-only state (the database, door ownership tables) keeps the hook server-only, with the server sending the outcome and the reason to the client. A prediction desync is accepted only as a known exception, tracked and fixed as soon as possible.

Lifecycle hooks

  • One hook per action, fired after the action is applied, so a listener sees the new state. Every action gets one, including those no player initiates (a property lost, a staff clear). Only player-initiated actions that can be refused also get a veto hook.
  • Name: <Object><Verb-ed>, the object first so a system's hooks sort together: RoomWallpaperCreated, RoomWallpaperUpdated, RoomWallpaperRemoved.
  • One hook per outcome, with a cause argument. Different causes of the same outcome (removed by its owner, by property loss, by a staff clear) fire the same hook. A listener that cares whether the room is gone then cannot miss a route, which is how restrained_search came to clear its state on two of the seven ways a restraint comes off (#2199). Different outcomes, like create and remove, are different hooks.
  • Arguments: the actor first, nil when no player caused it; then the subject; then the cause and anything else. A removed subject is passed as it was. The veto hook takes the same arguments, minus anything known only afterwards.
  • A default too: an empty GM: method, which carries the hook's documentation.
--- Called after a notice is taken off its board.
--- @hook
--- @param actor Player? The player who removed it, or nil when no player did.
--- @param notice table The notice as it was before removal.
--- @param cause string `"removed"` (by its author), `"expired"` or `"staff_clear"`.
function GM:NoticeRemoved(actor, notice, cause) end

A value that changes on its own, a single networked field rather than a system object with several fields or actions, takes no lifecycle hook of its own: use the network var's :SetHook() or :AutoHook(), which pass (ent, new, old, predicted) (see networking.md).

Documenting a hook

Document the hook on its GM: method: the description, --- @hook, and the usual @param and @return. A lifecycle hook lists every cause value in that parameter's description. See luacats.md for the tag.

Testing a veto hook

A new veto hook ships with GLuaTest cases that pin the wiring, not just the rule:

  • a hook.Add callback allows a player the default rule refuses;
  • a hook.Add callback cannot refuse a guarded case (staff are still allowed);
  • the default rule, called directly as GAMEMODE:CanX(...), gives the expected answer for each case, apart from the hook.Run wiring.

Listening to a hook

A plugin listens through PLUGIN:AddHook(event[, func]), which owns the registration and replaces rather than stacks on a reload. See plugins.md.