Quick navigation

Go to

Jobs & world interaction

Actions and carrying

Lua reference for timed actions, work spots, eligibility, hooks and carrying. Connect your job rules to prompts, animations and progress bars.

19 min read4,232 wordsMarkdown
On this page10 sections

Use open77_actions when an activity needs a prompt, an animation, a duration and a result: repair a terminal, cook a dish, inspect a wreck or carry a crate. It provides the progress bar, checks whether the player can act, prevents two workers using an exclusive spot and cleans up the animation and controls when work ends.

Your Lua resource supplies the requirements and result. The action library does not define your recipes, inventory or pay rates. Connect those through its hooksβ€”the functions it calls before, after or when cancelling work.

This page is the reference for Lua authors. For an introduction and existing jobs, see Jobs and world interaction. For a complete example with installation, test items and payment, follow Build your first Lua job.

Action, target and hook

Term Meaning Example
Action What the player does. Defines the label, duration and animation. Repair, taking six seconds.
Target Where an action is offered. Several targets can use the same action. A work spot beside each of three machines.
Hook A Lua function your resource exports for an action to call. Check for a spare part or apply a payment.

These are configured work spots. To attach a simple prompt directly to another kind of object or NPC, see contextual interactions. Adding a target does not change a vanilla device's functionality.

Source: open77_actions.

Your first action

This example adds a repair prompt, animation and an in-memory repaired flag. It does not consume items, pay a reward or save the machine's state across restarts. The next section adds item and payment hooks; the full tutorial includes their setup.

-- my_repairs/open77.lua
resource "my_repairs"
version "1.0.0"
dependency "open77_actions >=0.1.0"
server_script "server/main.lua"
-- my_repairs/server/main.lua
local actions = exports.open77_actions
local broken = { machine_1 = true }   -- your own state

local function declare()
    actions:define({
        id = "repair",
        label = "Repair the machine",
        durationMs = 6000,                    -- the server's clock decides when it is done
        animation = { profile = "amb_1056d7_kneel_welder", at = "target" },
        progress = { label = "Repairing" },   -- the bar; X cancels
        hooks = { validate = "repairValidate", commit = "repairCommit" },
    })
    actions:target({
        id = "machine_1",
        position = { x = -1410.2, y = 958.4, z = 23.4 },   -- where the player stands: /pos on the spot
        yaw = 90.0,                                          -- the way they face while working
        label = "Broken vending machine",
        actions = { "repair" },                              -- the E choice
    })
end
CreateThread(declare)
AddEventHandler("open77_actions:ready", declare)   -- it forgets everything when it restarts

-- A hook is the NAME of one of your exports. Any resource can call an export: check the caller.
exports("repairValidate", function(ctx)
    if GetInvokingResource() ~= "open77_actions" then return false, "not_owner" end
    if not broken[ctx.target.id] then return false, "already_fixed" end   -- your own token
    return true
end)

exports("repairCommit", function(ctx)
    if GetInvokingResource() ~= "open77_actions" then return nil, "not_owner" end
    if not actions:verify(ctx.opId, "commit") then return nil, "not_owner" end   -- really ours, now
    broken[ctx.target.id] = nil
    return { fixed = ctx.target.id }
end)

No client script and no permission are needed: open77_actions draws the prompt, the bar and the control locks itself. It is opt-in: the operator lists it in resources.load and starts it with ensure open77_actions before your resource. at = "target" places the body on the target (up to 5 m away) facing yaw, through Open77.animations.playAt; workspot animations switch the player to third person while they play. Job postures such as amb_1056d7_kneel_welder ship in Open77RP.archive, so players need a client archive that includes them (RP animations).

What happens when the player presses E

  1. Check requirements. The library checks the player's distance, life state, vehicle, combat and world instance, then calls validate with ctx.phase = "begin".
  2. Reserve the spot and materials. An exclusive target admits one worker. If present, your reserve hook holds the required items or money before any animation starts.
  3. Present the work. The animation and progress bar run for durationMs.
  4. Apply the result. The library checks the player again and calls validate with ctx.phase = "complete", then calls commit to apply your result.
  5. Finish. The library releases the spot and its control claims.

The server times completion. maxDistance defaults to 2.5 m, with 0.75 m of slack at completion. Use the hooks to add your server's own requirements and item/payment operations.

Any other end (death, disconnect, X, walking off, a car, aiming, being shoved out of the animation, a newer action, a restart of either resource) also releases the spot, stops the animation, gives the controls back and calls your cancel hook once. Every message carries an operation id (opId), so a late answer for an old action never touches the next one.

Hook When Runs Answers
validate steps 1 (ctx.phase = "begin") and 4 ("complete") synchronously: no Wait, no await true, or false, reason
reserve step 2 asynchronously a table (kept as ctx.reservation), or nil, reason
commit step 4 asynchronously a table (the receipt), or nil, reason
cancel once, on any end that is not a commit asynchronously ignored
marker at each of the action's markers (ctx.marker) synchronously ignored

Keep the tables reserve and commit return under 2 KB. verify(opId, "reserve" | "commit") answers true only for your own operation, in that phase.

Take a fuse and pay 40 eddies

To consume a part and pay the worker together, use open77_transactions: hold the transfer in reserve, apply it in commit and release an unused hold in cancel. Retrying the same transaction id returns its existing result instead of applying another payment. A new id can pay again. Add dependency "open77_transactions >=0.1.0" to the manifest, and reserve = "repairReserve" and cancel = "repairCancel" to the action's hooks:

local tx = exports.open77_transactions
local function call(name, ...)   -- mutating coordinator exports yield: use the promise form
    local promise, why = Open77.exports.call("open77_transactions", name, ...)
    if not promise then return nil, why end
    return promise:await()
end

exports("repairReserve", function(ctx)
    if GetInvokingResource() ~= "open77_actions" then return nil, "not_owner" end
    if not actions:verify(ctx.opId, "reserve") then return nil, "not_owner" end
    local pockets, cash = tx:account(ctx.playerId, "char"), tx:account(ctx.playerId, "cash")
    if not pockets or not cash then return nil, "no_account" end
    local txOpId = "repairs:" .. ctx.opId                 -- one business id per action
    local held, why = call("reserve", { opId = txOpId, kind = "repair", lines = {
        { item = "fuse", count = 1, from = pockets, to = "sys:repairs" },   -- the fuse is used up
        { money = 40, from = "sys:repairs", to = cash },                    -- the pay
    } })
    if not held then return nil, why end                  -- insufficient_items: no bar starts
    return { txOpId = txOpId }                            -- becomes ctx.reservation
end)

exports("repairCommit", function(ctx)
    if GetInvokingResource() ~= "open77_actions" then return nil, "not_owner" end
    if not actions:verify(ctx.opId, "commit") then return nil, "not_owner" end
    local receipt, why = call("commit", ctx.reservation.txOpId)
    if not receipt then return nil, why end
    broken[ctx.target.id] = nil
    return { paid = 40 }
end)

exports("repairCancel", function(ctx)
    if GetInvokingResource() ~= "open77_actions" then return end
    if ctx.reservation and ctx.reason ~= "in_doubt" then tx:cancel(ctx.reservation.txOpId, ctx.reason) end
end)

If commit answers in_doubt, busy or an export timeout, the library calls it again with the same context (commitRetries, 2 by default), and the coordinator replays a commit that already happened. Still unknown after the retries, the action ends in_doubt, not failed: leave the hold alone and settle it with the coordinator's status. An answer that says nothing was applied (store_unavailable, coordinator_unavailable, conflict) is retried the same way, then ends failed. The operator must let my_repairs write pockets and create the pay (writers and mint rules, in Durable transactions).

Tell the player why it was refused

When What the player sees by itself
a prompt is refused at the start (your validate hook, the claim, the distance) a short sentence for common reasons ("Too far away."), otherwise "Not possible right now."
the action ends after the bar started a short sentence ("You moved away.", "Someone else took it."), otherwise "Stopped."; nothing after X or a newer action
your reserve hook refuses, or runs out of time nothing: the action ends before anything was shown
the action commits, or your own request call is refused nothing

So tell the player yourself, from the open77_actions:transition event. Every resource receives it: filter on op.owner.

local RESOURCE = GetCurrentResourceName()
local TEXT = { insufficient_items = "You need a fuse.", no_account = "Your character is not ready." }

AddEventHandler("open77_actions:transition", function(op)
    if op.owner ~= RESOURCE then return end
    if op.state == "failed" and op.stage == "reserve" then
        Open77.chat.send(op.playerId, TEXT[op.reason] or ("You cannot repair this: " .. op.reason))
    elseif op.state == "committed" then
        Open77.chat.send(op.playerId, ("Machine repaired: +%d eddies."):format(op.receipt.paid))
    end
end)

The built-in sentences cover target_claimed, spot_taken, too_far, carrying, weapon_drawn, not_carrying, actor_busy, in_combat and player_in_vehicle. To word any other reason, your own tokens included, give the action messages: a reason and the plain text shown instead, or false to show nothing when your resource tells the player itself. Up to 96 entries of 200 characters each.

actions:define({
    id = "repair", label = "Repair the machine", durationMs = 6000,
    hooks = { validate = "repairValidate", commit = "repairCommit" },
    messages = {
        already_fixed = "This machine already works.",   -- your validate hook's token
        not_eligible = "Clock in first: /service.",
        moved = false,                                    -- your own handler words this one
    },
})

Start an action from your own code

A prompt is not required. From a command, an NPC event or your own net event:

local op, reason, info = actions:request(playerId, "repair", "machine_1", { supersede = true })
if not op then Open77.chat.send(playerId, "Refused: " .. reason) end

request answers at once with a summary (op.opId, op.state...), or nil, reason, info where info.stage names the step that refused and info.retryable is true for target_claimed, destination_claimed and actor_busy. The options are supersede (replace the player's running action, which ends superseded) and data (up to 2 KB, given to hooks as ctx.data). With a reserve hook, begin takes the same arguments and also waits for the reservation; it yields, so call it as Open77.exports.call("open77_actions", "begin", ...):await() from a thread. A resource starts only its own actions; cancel(opId, reason) stops one, status(opId) and current(playerId) read the summary.

Place the prompt and choose its keys

The target's position is where the player stands, usually a floor point (stand there, type /pos). The card is drawn promptHeight (1 m) above it and fires once the player is within distance (2 m) and the card is on screen; there is no need to aim at it. The server then checks the action's maxDistance (2.5 m): keep it at least as large as the target's distance.

A few target fields change where the card is drawn and who sees it, not what the server allows:

actions:target({ id = "station", position = station, yaw = 55,   -- where the worker stands
    promptAt = seat,             -- draw the card over the patient's seat instead (within 3 m)
    hideFor = { patientId },     -- the patient gets no card for this target
    keepCrowdOff = true,         -- pedestrians keep 0.8 m off the stand point
    actions = { "operate" } })
  • promptHeight (0 to 3 m, 1 m by default) is the card's height above its point. Lower it when the point is already at the height of the thing, such as a counter top.
  • promptAt draws the card promptHeight above another point, within 3 m of position. The player still stands at position, the server still measures distance from there, and the card is live wherever position is in reach. Use it when the card would otherwise sit under the worker's own feet, out of view: a ripperdoc's station shows its card over the patient.
  • lookAt = true also requires the crosshair on the card. Use it only for spots closer together than their distance, where the player picks by looking; otherwise the nearest card in reach wins.
  • hideFor lists up to 8 player ids who get no card for this target. It only hides the card: an intent one of them sends is still judged by the action's own rules. A downed patient is not shown the medic's card over their own body. Call target again with the same id to change the list.
  • keepCrowdOff keeps the crowd's pedestrians off the stand point while the target exists: true for 0.8 m, or a radius from 0.3 to 1.5 m. It needs open77_pedestrians running and does nothing without it. Walkers stay out; a pedestrian already standing there is not moved.

A target offers up to four choices, on E, F, R and Y in order, unless a choice names its own key (one letter or digit). G (put down), T (it opens the chat) and X (cancel) are never given by default. Each action must be defined first:

actions:target({ id = "machine_2", position = { x = -1404.8, y = 961.0, z = 23.4 }, yaw = 90,
    label = "Vending machine", actions = {
        "repair",                                              -- E, with the action's own label
        { action = "inspect", label = "Check the fuse box" },  -- F
        { action = "restock", key = "Q", holdSeconds = 1 },    -- Q instead of R, held one second
    } })
  • A card shows its first choice's label and key, then "+N more". The other choices fire on their keys, but the player cannot read them: put the common choice first, give rare ones their own target or a command. F is also the game's own interact key.
  • Keys work only on the nearest card in reach: a crate next to the machine takes them over.
  • Use plain ASCII in labels: a character such as the euro sign shows as ?.
  • target with an existing id replaces that target; untarget(id) removes it.

Spots that push the player back

playAt counts the body as arrived within 0.5 m of the spot, and a body that drifts past that ends the action (animation_interrupted). A grill behind its counter, or a bar stool (which stops the body 0.73 m from its anchor), pushes the body back. Raise the radius there, up to 1.5 m:

actions:target({ id = "grill", position = { x = -397.6, y = 715.1, z = 115.0 }, yaw = 64.0,
    arrivalRadius = 1.0, actions = { "cook" } })
-- or per action, which wins over the target's: animation = { ..., arrivalRadius = 1.0 }

A player already on the spot

An action with animation = { at = "target" } places the actor on the target's stand point. While another player stands within 0.45 m of that point (and within 2 m above or below it), the action is refused with spot_taken and the player reads "Someone is standing there.": placing the body would put it on top of the other one.

  • The check runs after the player's own state, the job and duty checks, the epochs and your validate hook, so an off-duty player is told that first.
  • A player working this same target is left to the target's claim: the second player reads target_claimed ("Someone is already using this.").
  • A player held in a placed action at another target does not block: a patient seated in a chair 0.7 m from the ripperdoc's station does not stop the ripperdoc.
  • A neighbour half a metre off the point, such as a helper beside a bench, does not block either.
  • Actions played at = "self", or without an animation, are never refused this way.

Only on duty: jobs, grades and duty

The library knows nothing about jobs. Plug in your framework:

actions:define({
    id = "repair", durationMs = 6000,
    -- asked at the start and before the commit: any export answering true, or nil + reason
    eligibility = { resource = "rp_jobs", export = "eligible", args = { job = "technician", duty = true } },
    -- read at the start; if it changed by the commit (job, grade or duty changed), refused
    epochs = { resource = "rp_jobs", read = "workEpoch", event = "rp_jobs:workEpoch" },
})

The eligibility export is called synchronously with (playerId, args); its reason (such as off_duty) reaches the player, or not_eligible when it gives none. Each field also takes a list of up to four, which must all pass. When the epoch event fires for the player with a new value, the running action ends at once (stale_job_epoch) and a carried item is dropped.

Carry a crate to a truck

Use the carry helper for the visible pickup, carrying pose and put-down interaction. Your resource still owns the goods and must save their contents and placement. If you use the RP pack, start with rp_salvage and rp_cargo for a complete inventory and vehicle integration.

The following is an integration sketch. Supply myTruckNear, truckIsFull and saveCrate from your vehicle access and storage code; they are not Open77 exports. Also register the cratePickup hook described below. The snippets do not form a standalone cargo job.

A scrapper finishes cutting a wreck and a crate appears beside it. The card reads E Pick up scrap crate; they lift it and walk to their truck at walking pace, unable to sprint, jump or fight. Others see the crate in their hands; in first person it sits low on the right, under a "Put down scrap crate" hint. G (rebindable in Settings, Key bindings) sets it down in front of them.

actions:carryDefine({
    id = "crate",
    model = "crate.ammo_box",        -- a box at hand scale
    label = "scrap crate",           -- the card reads "Pick up scrap crate"
    hooks = { pickup = "cratePickup", putdown = "cratePutdown", dropped = "crateDropped" },
})

-- A crate on the ground: its pickup prompt (E) appears by itself.
local itemId = actions:carryPlace("crate", {
    position = { x = 1369.6, y = -1689.4, z = 49.3 }, yaw = 0, bucket = 0,
    data = { crate = "crate_17" },   -- your own id: every hook receives it
})

-- Into the truck, from your own command or prompt.
RegisterCommand("load", function(source)
    local truckId = myTruckNear(source)   -- yours: the vehicle id of a truck this player may use
    if not truckId then return end
    local op, why = actions:carryPutdown(source, { kind = "vehicle", vehicleId = truckId,
        offset = { x = 0.0, y = -1.5, z = 1.0 },      -- in the truck's frame: the bed
        claimKey = "truck:" .. truckId .. ":slot1" }) -- one crate per slot
    if not op then Open77.chat.send(source, "Cannot load: " .. why) end
end, false)

vehicleId is the engine's 64-bit vehicle id (any whole number up to 2^53), and the player must stand within vehicleReach (4 m) of the truck. Custody changes only in your putdown hook: the library moves the prop after your hook commits, and never decides who owns the goods. For a durable record, move the crate with a coordinator transfer there (into veh:<plate>:cargo for a truck); if it refuses, return its reason and the player keeps the crate. Lifting moves no value, so cratePickup can simply check its caller and answer { crate = ctx.carry.data.crate }.

exports("cratePutdown", function(ctx)
    if GetInvokingResource() ~= "open77_actions" then return nil, "not_owner" end
    local dest = ctx.carry.destination               -- kind = "ground", "point" or "vehicle"
    if dest.kind == "vehicle" and truckIsFull(dest.vehicleId) then
        return nil, "truck_full"                     -- refused: the player keeps carrying it
    end
    saveCrate(ctx.carry.data.crate, dest)            -- yours: write where it is now
    return { placement = dest }
end)

exports("crateDropped", function(ctx)   -- ctx.reason says why; ctx.data is the item's data
    if GetInvokingResource() ~= "open77_actions" then return end
    saveCrate(ctx.data.crate, { kind = "point", position = ctx.position })
end)

placement says where the prop goes: { kind = "ground" }, { kind = "point", position, yaw }, { kind = "vehicle", vehicleId, offset, rotation }, or { kind = "remove" } once the crate is sold or handed to another resource (rp_salvage hands truck loads to rp_cargo this way). Without placement, the destination is used.

A target that offers the built-in put-down is a drop zone with no code of its own: its card shows only to players who carry something, and the item lands on that point. Your putdown hook sees ctx.target, and sells the crate by answering { placement = { kind = "remove" } }:

actions:target({ id = "counter", position = { x = 1375.1, y = -1684.0, z = 49.3 }, yaw = 90,
    label = "Scrap buyer", requires = "carrying", data = { bay = "sell" },
    actions = { { action = "open77_actions:carry_putdown", label = "Sell the crate" } } })

What happens to a carried crate

Event What happens to the item
your pickup hook refuses it goes back exactly where it was
your putdown hook refuses, or the destination is no longer valid the player keeps carrying it
the server teleports the carrier the hold resumes when the player is back; after 15 s away, it is dropped
death, disconnect, a vehicle, a bucket change, combat, the carry pose broken, your resource stopping, a job change dropped at the player's feet; your dropped hook gets ctx.reason; custody is unchanged
the truck attachment fails after your commit retried, then left on the ground (projection_failed); the commit stands

A restart of your resource or of open77_actions removes your carry items: place them again from your own record when you start and on open77_actions:ready, as rp_salvage does. carryPickup(playerId, itemId) starts a pickup from your code, also from a truck bed (players cannot lift from a truck through a prompt). carrying(playerId) and carryGet(itemId) read the state, carryRemove(itemId) deletes an item, carryDrop(playerId) forces a drop. Only the resource that placed an item may change it.

Keep a spot for yourself

Benches and anything else you run yourself can use the same one-winner claims:

local token, why = actions:claim(bucket, "bench:" .. benchId, 60000)   -- or nil, "target_claimed"
actions:renewClaim(token, 60000)
actions:releaseClaim(token)

A claim lasts its time (1 s to 1 h, 30 s by default) unless renewed, and goes when your resource stops or reloads; claimHolder(bucket, key) says who holds a key. Players seated on world furniture hold furniture:<catalogue version>:<furniture id> (Furniture): claim it to keep players off a table your job uses.

On the server console, actions.status [playerId] shows what runs and who carries what, actions.claims [bucket] lists the claims, and actions.trace (actions.trace off) logs every step of every action.

Reference

Action fields

Field Default What it does
id, durationMs required up to 48 letters, digits, _ . -; 250 to 600,000 ms on the server's clock
label the id the choice and bar label
commitAtMs durationMs commit earlier; the animation still runs to the end
maxDistance 2.5 m the server's distance check (0.5 to 25), plus completeSlack (0.75 m) at the end
animation none { profile, at = "self" or "target", yaw, yawOffset, clip, loop = true, required = true, arrivalRadius }; with required, an animation that cannot start fails the action
progress a bar with the label { label, position = "bottom", "top" or "center", style = "bar" or "circle", color = "#rrggbb", cancellable = true }, or false
claims { move = true, attack = true } controls locked while it runs: move, attack (firing and punching), jump, sprint, walkSpeed (0.5 to 2.5 m/s); false for none
claim, claimTtlMs true, duration + 30 s one player per target at a time (false: several), for that long (1 s to 1 h)
cancelOn { combat = true, vehicle = true, moved = true, weaponDrawn = false } which events end it
moveTolerance 1.0 m without an animation, moving this far ends it (moved)
maxPositionAgeMs 2,000 an older server position is refused (position_stale)
reserveTimeoutMs 20,000 a slower reserve hook ends it (reserve_timeout)
commitRetries 2 retries of an unsure commit (0 to 5)
cooldownMs 0 after a commit, the same player waits this long (actor_busy)
supersede, requiresTarget, whileCarrying false, true, false replace a running action; refuse request without a target; allow it while carrying
markers none up to 8 { name, atMs }: the marker hook and event fire then

Target fields

Field Default What it does
id, position, actions required up to 48 characters; where the player stands; 1 to 4 choices
a choice an action id, or { action, label, key, holdSeconds (0 to 10), icon }
yaw, bucket none, 0 the way the player faces with at = "target"; the routing bucket
label, description none the fallback choice label; the choices' description
distance, promptHeight 2.0 m, 1.0 m reach of the card (0.5 to 25); its height above position (0 to 3, lower for a counter top)
markerDistance 12 m from how far the dot is drawn (1 to 120)
marker, color, icon dot the dot's shape (Interactions), #rrggbb, a short icon
lookAt false also require the crosshair on the card, for spots closer together than their distance
promptAt none draw the card over this point instead, within 3 m of position; the reach is still measured from position
hideFor none up to 8 player ids who get no card for this target; an intent they send is still judged as usual
keepCrowdOff none true (0.8 m) or 0.3 to 1.5 m: the crowd's pedestrians keep off the stand point (needs open77_pedestrians)
arrivalRadius 0.5 m how close playAt must bring the body (0.5 to 1.5)
requires none "carrying" or "empty": offer the card only to players with, or without, an item
data none your table (up to 1 KB), given to hooks as ctx.target.data

Carry fields

Field Default What it does
id, model, label required, required, the id a prop alias such as crate.ammo_box; the name in "Pick up ..."
reach, vehicleReach 2.0 m, 4.0 m pickup distance (0.5 to 5); distance to a truck (1 to 8)
dropAhead 0.7 m how far in front of the player G or a drop sets it (0 to 2)
promptHeight 0.2 m how far the "Pick up" card sits above the item's top (0 to 2). The item's target stands on its base, the point the server measures reach from, so a card drawn in reach is one the server accepts
gripMs, releaseMs 60 % of the pickup clip, 55 % of the put-down clip when the hands close and open, on the server clock: calibrate them
pickupProfile, holdProfile, putdownProfile carry_pickup, carry, carry_putdown the animations; pickupMs and putdownMs override the clip lengths
binding on the chest, between the hands { bone, offset, rotation, firstPerson }; another model may need its own offsets; firstPerson = "hide" hides it in first person
claims, cancelOn { walkSpeed = 1.2, jump = true, attack = true, sprint = true }, { combat = true, weaponDrawn = true } what the carrier cannot do; aiming or a drawn weapon drops it (a vehicle always does)
hooks, eligibility, epochs none validate, pickup, putdown, dropped, cancel; job checks as for actions

Hook context

Field What it holds
opId the operation id: derive your business ids from it
action, actionId, owner "my_repairs:repair", "repair", "my_repairs"
playerId, bucket, phase the player, their bucket at the start, and begin, complete, reserve, commit, cancel or marker
kind action, carry_pickup or carry_putdown
target { key, id, position, yaw, data }, or nil without a target
data, reservation, receipt the data option of request; what reserve and commit returned
carry carry hooks: { itemId, kind, data, destination }
reason, marker cancel: why it ended; marker: the marker's name
playbackId, startedAtMs, generation, ownerGeneration animation and restart bookkeeping

The dropped hook gets { itemId, kind, playerId, reason, position, data, sessionId }. The exports are define, undefine, target, untarget, request, begin, cancel, status, verify, current, claim, renewClaim, releaseClaim, claimHolder, carryDefine, carryPlace, carryPickup, carryPutdown, carryDrop, carryRemove, carryGet and carrying. All answer at once except begin; a refusal answers nil, reason.

Events

Server events every resource receives: filter on owner.

Event Payload
open77_actions:ready { generation, version }: open77_actions started; declare again
open77_actions:transition { opId, action, actionId, owner, playerId, bucket, target, state, reason, stage, receipt, kind, itemId, playbackId, startedAtMs, completeAtMs, trailing }
open77_actions:marker { opId, marker, owner, playerId, playbackId }
open77_actions:carry { phase, itemId, kind, owner, state, carrier }, plus playerId, reason, opId or placement by phase

state goes validating, reserving (with a reserve hook), presenting, committing, and ends committed, cancelled, failed or in_doubt. stage names the step an end came from: request, validate, reserve, present, complete, commit, cancel or carry. target is the target key ("my_repairs:machine_1"). Carry phases: placed, gripped, picked_up, put_down, dropped, removed, carry_suspended, carry_resumed, projection_failed, and pickup_<state> or putdown_<state> when a pickup or put-down ends without a commit.

Why was it refused

Reason What it means What to do
target_claimed another player holds the spot wait: it frees up when they finish
spot_taken another player stands on the stand point of an action placed at = "target" wait for them to step off (details)
destination_claimed another item holds that put-down claimKey use another slot
actor_busy the player already runs an action, or cooldownMs has not passed wait, or pass supersede = true
too_far beyond maxDistance, or the item's reach move closer; keep maxDistance at least the target's distance
destination_too_far over reach + 1 m (5 m at most) from the drop point, or beyond vehicleReach stand closer
wrong_bucket not in the bucket of the target, item or truck check the target's bucket
position_stale, position_unavailable, player_not_ready, player_unavailable no fresh position yet, still loading in, or gone retry once the player is in the world
player_not_alive, player_in_vehicle, in_combat dead, in a vehicle, aiming or firing retry once alive, on foot and not aiming
carrying hands full, and the action has no whileCarrying put the item down, or set whileCarrying = true
not_carrying, item_unavailable nothing in hands; the item is gone or someone else is lifting it nothing
destination_invalid, vehicle_unavailable a malformed destination; no vehicle with that id check kind, vehicleId, claimKey
not_eligible, or the provider's token your eligibility export said no clock in, or get the job
stale_job_epoch job, grade or duty changed since the start start again
refused_by_owner your hook said no without a reason return your own token, such as already_fixed
hook_failed your hook raised an error read the server log
animation_refused, attach_failed the animation could not start; the carried prop could not be held check the profile or the model, or set required = false
owner_unavailable, not_owner, unknown_action, unknown_target, invalid_* a reload since define, someone else's action or item, a failed verify, a wrong id or table declare again; fix the call
cancelled_by_player, superseded X on the bar; a newer action nothing
dead, disconnected, bucket_changed, vehicle, combat the player died, left, changed bucket, got in a car, aimed or fired nothing
moved moved past moveTolerance (an action without animation), or a server move took the body nothing
animation_interrupted the posture broke: shoved, pushed off the spot, teleported raise arrivalRadius at spots that push back
claim_lost, reserve_timeout the claim ran out (claimTtlMs); your reserve hook was too slow (the player sees nothing) raise the limit, or make the hook faster
owner_stopped, resource_stopping, cancelled your resource or open77_actions stopped; cancel(opId) without a reason nothing
in_doubt the commit's outcome is still unknown after the retries settle it with the coordinator's status

A start reason can also end a running action, when the end-of-time checks refuse it (too_far, not_eligible, your token). A commit that answers nil, reason ends the action failed with that reason.

Limits

  • 64 actions and 64 targets per resource, 512 targets per server, 128 carry items per resource, 4 choices per target, 8 markers per action.
  • A player sees at most the 12 nearest prompts, and their client sends at most six choices a second.
  • The walk pace and the sprint, jump and fighting locks of a carry are applied by the player's client; the server sees positions, not inputs. A client too old to block melee or sprinting answers action_not_blockable, and the carry runs without those two locks.
  • Identical targets stacked on one point (within 5 cm, with the same choices, reach and card), such as two crates set down on the same spot, show one card: the target with the smallest key. E acts on that one, and the next takes the card once it is gone. Two different things on one point keep a card each.
  • There are no station "recipes": add a stage with your own hooks or a second action, as Build your first Lua job shows.

See also: Durable transactions, Furniture, Interactions, Player interactions, RP animations, Props, UI kit.

// For agents & LLMsthis page as Markdownllms.txtllms-full.txt

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try