Jobs & world interaction
Items, payments and storage
Connect inventories and wallets, save recipes and sales as one operation, handle payment retries and create persistent world containers.
On this page17 sections
- Choose your inventory integration
- Reserve, commit and cancel
- Five ideas
- Install
- Accounts
- Declare your items
- Pay a player for a job
- Give an item
- Sell to another player
- Stock a stall: stashes
- Read balances and history
- Unique items and metadata
- Durable world containers
- Who may write, and who may create value
- Refusals
- Console
- Limits
Use open77_transactions to save the item and money changes that belong together. A recipe can
consume ingredients and create a meal in one operation. A sale can transfer that meal to the
customer and pay the business in the same operation.
An operation is one group of transfers. They are saved together in a database transaction: all of its lines commit, or none do. Its operation id identifies the result, so retrying the same id returns a saved receipt without repeating the transfer. Creating a new id creates a new operation; your job must keep the original id when retrying an uncertain payment.
This page is for authors integrating inventory, recipes, sales or storage. Server owners enabling an existing job should start with Jobs and world interaction. For a worked Lua example, see Build your first Lua job.
Choose your inventory integration
| Your server uses⦠| Integration to use |
|---|---|
| The RP pack's inventory and economy | Define items through rp_inventory; use its asynchronous transfer operations and rp_economy sales/payments. Follow the salvage or street-food example. |
| Your own inventory and economy | Use the coordinator directly and decide which resource manages each account, item definition and capacity. |
| No inventory yet; you want to learn | Follow the repair tutorial, which creates test pockets and fuses directly. |
The RP inventory's transactions backend uses this coordinator underneath. Its legacy
synchronous exports can acknowledge a queued change before it is saved. New job code should
wait for asynchronous transfer results before treating an item exchange as complete.
The coordinator saves the transfers you submit. It does not also save a machine's repaired state, deliver a notification or apply a health effect. Your resource must manage those results and their recovery separately. Avoid treating a durable payment as proof that every step of a job has finished.
Source: open77_transactions, open77_containers.
Reserve, commit and cancel
For timed work, reserve the planned transfer before the animation starts. Commit it after the work succeeds, or cancel the reservation if it ends before committing. The three numbered blocks below belong in separate action hooks; the cancel block is an alternative to committing, not a step to run after a successful commit. Complete hook definitions are in the repair tutorial.
-- my_repairs/server/main.lua: each step runs inside one of your action's hooks
local function tx(name, ...)
local promise, why = Open77.exports.call("open77_transactions", name, ...)
if not promise then return nil, why end
return promise:await()
end
-- 1. Before the progress bar: hold one fuse and the pay.
local pockets = exports.open77_transactions:account(ctx.playerId, "char") -- "char:<accountId>"
local cash = exports.open77_transactions:account(ctx.playerId, "cash") -- "cash:<accountId>"
local opId = "repairs:" .. ctx.opId
local held, why = tx("reserve", { opId = opId, kind = "repair", actor = pockets, lines = {
{ item = "fuse", count = 1, from = pockets, to = "sys:repairs" }, -- the fuse is used up
{ money = 40, from = "sys:repairs", to = cash }, -- 40 eddies are paid
} })
if not held then return nil, why end -- "insufficient_items": no fuse, so no bar at all
-- 2. The bar finished: make it final.
local receipt, err = tx("commit", opId)
-- 3. Or the bar was interrupted: release the hold. Nothing moved.
exports.open77_transactions:cancel(opId, "moved_away")
Most exports wait for the database, so call them through tx from a thread, an event handler, a
command or an action hook; the plain exports.open77_transactions:transfer(...) form answers
export_yielded. These never wait: account, subjectOf, newOpId, define, info, health,
allowed, authorize, cancel, submit, and read of an account already in memory. A call
answers its result, or nil, reason, info; info.retryable says whether trying again can help.
Five ideas
- One operation is one database transaction: all its lines commit together, or none do.
- Reuse the id for a retry: committing an id again returns its stored receipt with
replay = trueand moves nothing. A new id is another operation, even for the same player and job. - Reserve holds value before the work, so a player with no fuse is refused before the bar.
- Commit makes it final, cancel releases an unused hold: nothing moves before the commit. If the database stops answering during commit, resolve that result before deciding it failed.
in_doubtmeans "not sure yet": the database stopped answering mid-commit. Retry with the same id, or askstatus(opId); a new id could pay twice.
Install
Add dependency "open77_transactions >=0.1.0" to your manifest, only in a server-only resource. A
resource that also ships a client script must leave it out (the client refuses it with
missing_dependency:open77_transactions) and wait for the open77_transactions:ready event.
The operator turns it on once: list it (selecting which resources load), then start it before the resources that use it.
resources.load: [ ..., "open77_transactions", "my_repairs" ]
ensure open77_transactions
ensure my_repairs
It needs a MariaDB or MySQL database (SQL database setup). At its first start it
creates its op77_tx_* tables (InnoDB) and logs ready. Just before that, its start-up self-test
logs one expected error: a transaction built to fail, proving the database really rolls back.
[database] resource 'open77_transactions' transaction statement 2/2 failed: database_conflict (SQL 1062): β¦
[resource:open77_transactions] ready: store=sql schema=v1 tag=srv
If the database is missing, unreachable at start, not InnoDB, or newer than this build, it refuses
to start: every call answers store_failed or store_unavailable; it never falls back to another
store.
Coming from the RP pack's 1.x rp_economy? The newer one refuses every wallet change with
migration_pending (log: β¦ 1.x wallet row(s) are not imported yet: wallets are refused until economy_migrate runs) until the operator runs economy_migrate on the console once. Nobody gets a
fresh starting balance and then the imported one, and a second run imports nothing twice.
Accounts
Everything that holds value is an account named <namespace>:<key>[:<part>]:
| Namespace | Holds | Example |
|---|---|---|
char |
the items a character carries (their pockets) | char:<accountId> |
cash, bank |
a character's money | cash:<accountId> |
soc |
a society's money | soc:mechanics |
stash |
items in a stash: a locker, a stall's pantry | stash:sf.food_court:pantry |
veh |
items in one part of a vehicle, keyed by plate | veh:NC-1234:cargo |
world |
a crate or pile on the ground; the coordinator picks the id | world:srv.lq2x8k3z.1 |
sys |
an unlimited source or sink, recorded in the journal only | sys:repairs |
- Ask for a player's accounts with
account(playerId, "char" | "cash" | "bank"), never build them. A session player id or runtime entity id is refused as a key (cash:12answersinvalid_key). - A container (
char,stash,veh) must exist before items move in or out (unknown_accountotherwise):opencreates it. A wallet is created by its first payment. - Moving value into
sys:burns it; moving it out creates it, which needs an operator's mint rule. Keepsys:names free of a second colon (sys:streetfood.kitchen) so a rule can name them.
Declare your items
Weights are in grams. Definitions live in memory, so declare them at start and again on
open77_transactions:ready (its argument is { store, schema }), which fires whenever the
coordinator is ready. On a server running the RP pack, define items through rp_inventory instead.
local function defineItems() exports.open77_transactions:define({ fuse = { weightG = 100 } }) end
CreateThread(defineItems)
AddEventHandler("open77_transactions:ready", defineItems)
define answers registered, rejected, collisions; an item another resource defined first stays
theirs.
Pay a player for a job
- The calls:
reserve(spec)answers{ opId, state = "reserved", expiresAt },commit(opId[, { lines }])the receipt,cancel(opId, reason)true. The spec needsopIdandlines(1 to 48);kind(a short label),actor,reason(also per line) andttlMsare optional. - The id: derive it from the job, never from a counter of your own:
"repairs:" .. ctx.opIdis one id per repair, so every retry of that repair reaches the same operation. - The hold expires after
ttlMs(default 30 s, at most 10 minutes) and is cancelled when your resource stops. Give it the length of the bar plus a margin. - Commit less than you reserved by passing final lines to
commit: less than the reservation, never more (exceeds_reservation). Send the same lines on every retry, or the replay answersop_id_conflict. - The receipt carries
opId,kind,actor,lines,reason,atandreplay. Withreplay = truethe commit had already happened: show the result again, pay nothing more. - In doubt,
cancelis refused: the coordinator keeps the hold and settles the operation once the database answers. Asktx("status", opId)until itsstateleavesin_doubt.
When the yield is unknown before the bar, reserve room out of a sys: source, then commit the
real items; a reservation made only of capacity lines needs final lines at commit. The scrapyard job
rp_salvage works this way, and
its server/ledger.lua (the only file that calls the coordinator) is a good shape to copy.
tx("reserve", { opId = opId, ttlMs = 15000, lines = { { capacityG = 2000, from = "sys:salvage", to = pockets } } })
tx("commit", opId, { lines = { { item = "scrap", count = 3, from = "sys:salvage", to = pockets } } })
Give an item
local pockets = exports.open77_transactions:account(playerId, "char")
tx("open", pockets, { capacityG = 30000 }) -- creates the pockets, or sets their capacity again
local opId = exports.open77_transactions:newOpId() -- make the id once, reuse it if you retry
local receipt, why = tx("transfer", { opId = opId, kind = "grant", lines = {
{ item = "fuse", count = 3, from = "sys:repairs", to = pockets },
} })
transfer is a reserve and a commit in one call, for anything without a bar. Creating fuses out of
sys:repairs needs a mint rule (mint_denied otherwise). With rp_inventory the pockets already
exist: do not open them, since open sets the capacity again.
Sell to another player
Collect the buyer's consent your way, then move goods and money in one operation. Make the id when
the offer is made, keep it with the offer, and reuse it on every retry. No value is created, so no
mint rule is needed: the resource only needs to write char, cash and soc.
local T = exports.open77_transactions
offer.opId = "sale:" .. T:newOpId() -- once, when the seller makes the offer
local sellerCash, buyerCash = T:account(offer.seller, "cash"), T:account(offer.buyer, "cash")
local receipt, why, info = tx("transfer", { opId = offer.opId, kind = "sale", reason = "two fuses", lines = {
{ item = "fuse", count = 2, from = T:account(offer.seller, "char"), to = T:account(offer.buyer, "char") },
{ money = 90, from = buyerCash, to = sellerCash },
{ money = 10, from = buyerCash, to = "soc:mechanics", reason = "guild share" },
} })
if receipt then -- done; receipt.replay == true means it had already gone through
elseif info and info.retryable then -- conflict, busy, in_doubt, store_unavailable: retry, SAME id
else -- insufficient_funds, insufficient_items...: tell both players, nothing moved
end
Stock a stall: stashes
A stash has a fixed name, so a restart finds the same goods with no lookup. The street-food stall keeps ingredients in a pantry and cooked dishes on a pass:
local PANTRY, PASS = "stash:sf.food_court:pantry", "stash:sf.food_court:pass"
tx("open", PANTRY, { capacityG = 40000 }) -- at start: creates it, or finds it again
tx("open", PASS, { capacityG = 2000 })
-- grilling a skewer, reserved before the bar: an empty pantry or a full pass is refused right here
local held, why = tx("reserve", { opId = cookOpId, kind = "streetfood.cook", ttlMs = 40000, lines = {
{ item = "sf_synth_meat", count = 2, from = PANTRY, to = "sys:streetfood.kitchen" },
{ item = "sf_sauce", count = 1, from = PANTRY, to = "sys:streetfood.kitchen" },
{ item = "sf_skewer", count = 1, from = "sys:streetfood.kitchen", to = PASS },
} })
The ingredients burn into the kitchen; the skewer is created out of it, which needs a mint rule. The complete stall, with a consented sale paid to a society plus a tip, is rp_streetfood.
Read balances and history
local acct, why = tx("read", cash) -- loads on demand, never creates: "unknown_account" if never paid
local rows = tx("history", cash, { limit = 20 }) -- newest first; page back with { before = <ms> }
A snapshot carries balance (wallets) or items, instances, weightG, capacityG (containers),
plus held (reserved) and pending (committing, not confirmed yet). History rows carry opId,
asset, delta, reason, at, kind, actor and resource. list(namespace, after, limit)
pages through the containers of char, stash, veh or world. The event
open77_transactions:committed(opId, kind, accounts) fires after every commit.
Unique items and metadata
A tool with its own wear is an instance: one object with an id you pick.
exports.open77_transactions:define({ crowbar = { weightG = 1500, unique = true } })
-- three lines, each usable in an operation:
{ instance = "cb-7f3a", item = "crowbar", from = "sys:dealer", to = pockets, meta = { durability = 20 } } -- create
{ instance = "cb-7f3a", at = pockets, metaPatch = { durability = 19 } } -- wear
{ instance = "cb-7f3a", from = pockets, to = "stash:locker_12" } -- move
meta replaces, metaPatch merges one level deep. One operation at a time can hold an instance; a
second answers instance_unavailable.
Durable world containers
A world prop disappears with its resource and the server process. A crate whose
contents must survive a restart (spare fuses in a workshop, a dropped pile, a wreck searched
three times) is an open77_containers container: the coordinator holds the contents and placement,
and open77_containers rebuilds the prop, prompt and summary at every start under the same id. List
it next to open77_transactions (with open77_interactions and open77_props) and ensure it.
-- boxes(name, ...) is the tx helper above, calling "open77_containers"
local crate
local function placeCrate()
crate = exports.open77_containers:find("workshop-fuses") or boxes("create", {
key = "workshop-fuses", -- your own name for it: after a restart, the same crate
kind = "crate", model = "crate.small", position = { x = -1411.0, y = 957.6, z = 23.4 }, bucket = 0,
capacityG = 20000, label = "Spare fuses", prompt = { label = "Take a fuse", key = "E" },
})
end
CreateThread(placeCrate)
AddEventHandler("open77_containers:ready", function() CreateThread(placeCrate) end)
AddEventHandler("open77_containers:used", function(id, playerId) -- a player pressed the prompt
if not crate or id ~= crate.id then return end
CreateThread(function()
local pockets = exports.open77_transactions:account(playerId, "char")
boxes("transfer", { opId = "take:" .. exports.open77_transactions:newOpId(), player = playerId,
lines = { { item = "fuse", count = 1, from = crate.id, to = pockets } } })
end)
end)
crate.id(world:srv.β¦) is the durable identity,crate.propIdonly today's prop. A keyedcreatefinds the same container again; a different spec under that key answersalready_exists.- A
cratestays when empty; apiledisappears when empty. - Moves go through
open77_containers(reserve,commit,cancel,transfer,status: the coordinator's shapes plusplayer), which checks rights, reach (reachM, 4 m) and bucket first. - The owner grants
take,put,target,consume,move,meta,readorcloseto another resource withgrant(id, grantee, rights, { ttlMs }), and takes them back withrevoke. find,summary,listandaccessanswer at once; the rest wait. Before its start-up rebuild,createanswersnot_ready; with the coordinator down, value calls answercoordinator_unavailable.
A node carries charges and, by default, holds nothing: a wreck searched three times, then once
more every five minutes. model = false puts its prompt on a point instead of a prop:
local node = exports.open77_containers:find("node.wreck_1") or boxes("create", { key = "node.wreck_1",
kind = "node", model = false, position = wreckPos, node = { charges = 3, regrowMs = 300000 } })
local state, why = boxes("consume", node.id, { ref = opId, player = playerId }) -- "depleted" when empty
The same ref never spends twice (state.replay). refill(id, { charges }) resets a node in place
(full by default) and keeps its id, grants and refs; open77_containers:depleted(id) and
open77_containers:regrown(id, charges) report the changes. Never close a node you want back: a
closed key is never reused.
Without open77_containers, a world: account is yours to draw. openWorld mints it, close
deletes it once empty (container_not_empty otherwise), setMeta(id, meta) changes its metadata,
and open77_transactions:world(id, change, meta) reports opened, meta and closed. At start,
page through listWorld(after, 100) and re-create each prop from its meta:
local pile = tx("openWorld", { capacityG = 50000, meta = { x = x, y = y, z = z, bucket = 0, model = "crate.small" } })
tx("transfer", { opId = dropId, lines = { { item = "fuse", count = 4, from = pockets, to = pile.id } } })
Who may write, and who may create value
Operators set both in the coordinator's tunables, from the Warden panel or the console
tunable.set open77_transactions <key> <value> (Operator tunables):
| Tunable | Default | Meaning |
|---|---|---|
writers |
* |
which resources may change which accounts; * lets every resource write (live) |
writers2 β¦ writers4 |
empty | more entries for the writers list, when it does not fit in one value; ignored while writers is * (live) |
mint1 β¦ mint6 |
empty | who may create what out of sys: accounts, and how much an hour (live) |
store |
sql |
the production store; a disposable store exists for unlisted development servers (restart) |
serverTag |
srv |
1 to 11 of a-z 0-9 _ -, a letter first; unique per server sharing a database (restart) |
preloadWorld |
2000 | world: containers loaded into memory at start (restart) |
retentionDays |
90 | journal and operation rows older than this are deleted; a replay needs its row |
workPerTick |
12 | coordinator steps per server tick |
- Writers:
*, or a comma-separated list.namemay write every namespace,name:ns+nsonly those,name:ns/partone part (rp_cargo:veh/cargowritesveh:<plate>:cargo, not the trunk). A resource left out answersnamespace_denied.writers2,writers3andwriters4continue the list with the same syntax: the coordinator joins the four values into one list. - Mint rules:
<resource> <sys:account or sys:*> <item, * or money> <capPerHour>, several separated by;;*is any item, never money. Nothing mints by default:mint_denieduntil a rule allows it, thenmint_cap_exceededonce the hour's cap is used. Burning intosys:needs no rule. Caps count over a sliding hour and survive a restart. A malformed entry is ignored and logged (writers: ignored entry 'β¦',mint rules: ignored entry 'β¦'). - 256 characters per value. A text tunable holds at most 256 characters. A longer value is
refused as a whole (
error: mint4: exceeds 256 characterson the console) and the previous value stays. A server running several jobs outgrows one value: continue the writers inwriters2towriters4, and spread the mint rules overmint1tomint6, each under 256 characters. A rule on*(any item) with one cap for the whole account is shorter than one rule per item.
The repair job, then a server running the RP pack's scrapyard (size the caps to your population):
tunable.set open77_transactions writers my_repairs:char+cash
tunable.set open77_transactions mint1 my_repairs sys:repairs money 20000
tunable.set open77_transactions mint2 my_repairs sys:repairs fuse 200
tunable.set open77_transactions writers rp_salvage:world+char, rp_cargo:veh/cargo, rp_economy:cash+bank+soc, rp_inventory, open77_containers:world
tunable.set open77_transactions mint1 rp_salvage sys:salvage * 2000; rp_inventory sys:* * 2000; rp_economy sys:* money 2000000
Adding the CHOOH2 chain and the techie to that server: the writers continue in writers2, and each
job's mint rules get a slot of their own next to the shared mint1:
tunable.set open77_transactions writers2 rp_chooh2:world+stash+char, rp_techie:stash/tray+char+cash
tunable.set open77_transactions mint2 rp_chooh2 sys:chooh2.fields * 2000; rp_chooh2 sys:chooh2.refinery chooh2_canister 400
tunable.set open77_transactions mint3 rp_techie sys:techie.workshop * 960; rp_techie sys:techie.dealer * 540
The roleplay jobs page lists the values for a server running all of them.
open77_containers creates the world: account of every container itself, and the coordinator sees
it as the writer of every move it forwards. With a writers list, give it world plus each
namespace your containers trade with (open77_containers:world+char for a crate players take from);
without world, every create answers namespace_denied. A container move that creates value
needs a mint rule for open77_containers and one for your resource.
To let another resource touch your accounts for one operation (the stall letting rp_economy sell
from its pass), authorize it first, for up to 16 accounts you may write yourself:
exports.open77_transactions:authorize(opId, "rp_economy", { PASS, buyerPockets }, 30000).
allowed(resourceName, "veh:NC-1:cargo") answers whether a resource may write an account; for a
sys: account (with an optional item or "money"), whether it may create value from it.
Refusals
| Reason | Retry? | Meaning |
|---|---|---|
invalid_spec, invalid_op_id, invalid_account, invalid_key, unknown_item, invalid_count, invalid_amount |
no | a bug or a forged input |
namespace_denied, mint_denied |
no | the operator's policy says no |
insufficient_items, insufficient_funds, capacity_exceeded, instance_unavailable, balance_limit |
no | the business answer; nothing is held |
unknown_account, container_not_empty, not_loaded |
no | the account does not exist, is not empty, or is not in memory |
already_committed |
no | reserve of an id that already committed; info.receipt is its receipt |
exceeds_reservation, operation_too_large |
no | the commit left its reservation, or needs more than 64 SQL statements |
expired, cancelled, unknown_operation, op_id_conflict, aborted, not_owner, stale_version |
no | lifecycle; after aborted, use a new id |
mint_cap_exceeded |
later | the hourly cap is used |
conflict |
yes | the database refused a stale view; plan again |
busy, in_doubt |
yes, same opId | still committing, or outcome unknown: ask status |
store_unavailable |
yes | starting, database down, or certainly not committed |
store_failed |
no | refused at start; an operator must act |
A refused, expired or cancelled id left no trace: the same id is a fresh attempt. status(opId)
answers { state, reason, receipt }; state is reserved, committing, committed, in_doubt,
failed, cancelled, expired, aborted or unknown.
When the database goes away, the coordinator logs store unreachable; mutating calls answer store_unavailable until a probe succeeds. Calls that had not started committing answer
store_unavailable: nothing was applied, so retrying is safe, and a reservation can still be
committed later. It probes every 2 s and logs store reachable again. An operation that was already
committing stays in_doubt with its holds until the coordinator settles it: poll status(opId)
with that same id, never a new one.
Console
transactions health # phase, store, counters
transactions mints # every mint rule with this hour's use
transactions read <account> # an account already in memory
transactions status <opId>
transactions resolve <opId> # settles an unknown opId for good: committed or aborted, never both
Use resolve only on an operation that may still be in flight from before a restart: an id it
marks aborted can never commit.
Limits
48 lines per operation and at most 64 SQL statements per commit; operation ids of 8 to 96 characters
(letters, digits, _ . : @ -); account ids up to 96 bytes; metadata up to 2 KiB; reasons up to 64
bytes; history up to 200 rows a page, list up to 500. A container is read with at most 512
stacks and 512 instances.
// For agents & LLMsthis page as Markdownllms.txtllms-full.txt
