Transactions
Nothing mutates a container directly. Every change — add, move, split, craft, buy — is a transaction: a plain data struct, executed by a registered executor, with every mutation journalled as it happens so a failure leaves nothing half-applied.
Why
A move between two containers touches two of them. A craft consumes several stacks and inserts one. A purchase takes coins from a buyer and an item from a vendor, on two different actors. Any of those can fail on the last step, and "half done" is not an acceptable outcome for a player's things.
Request ──▶ Validate ──▶ Execute (journalling) ──┬─ success ─▶ commit + broadcast
└─ failure ─▶ replay each inverse, in reverse
Validate never mutates. Execute is the only thing that does. Because both consult the same acceptance gate, a dry run and the real call cannot disagree.
Rollback records what actually changed — one entry per added, removed or updated item — and undoes those in reverse. It used to copy every affected container's whole contents before every transaction, which cost the same whether anything went wrong or not, and cost it in proportion to how much the player was carrying rather than to how much the move touched.
Using them
Most of the time you do not build a transaction. The ergonomic calls do it for you:
Container->TryAddItem(Definition, 3); // FEDAddItemTransaction
Container->TryTransferItem(Handle, Target, 2); // FEDTransferTransaction
Container->RequestSort(EEDSortMode::ByCategory); // FEDSortTransaction
Build one by hand when you need a field the wrapper does not expose — a specific target cell, for instance:
FEDMoveTransaction Move;
Move.SourceContainer = From;
Move.TargetContainer = To;
Move.Handle = Handle;
Move.TargetAddress = FEDSlotAddress::AtCell(FIntPoint(2, 1));
Container->RequestTransaction(FInstancedStruct::Make(Move));
Try or Request
Two families, and the difference is who is allowed to call them.
Try* | Request* | |
|---|---|---|
| Where | Authority only | Anywhere |
| Returns | Whether it happened, now | Nothing; the result arrives later |
| On a client | Returns false, does nothing | Travels to the server |
| Result | The return value | OnTransactionResult on the requesting component |
Use Try* inside server code — a Server RPC, an authority-gated branch. Use Request* from UI and
anything else that might be running on a client.
Try* is four primitives: Try Add Item, Try Remove Item, Try Remove Items and
Try Transfer Item. Every other player-facing operation is a Request* only. The rest used to
have a Try twin as well, and what those really offered was a second name for each action and a way
around the routing. Server code that needs one of them builds the transaction and runs it.
Authorization
A transaction is a data struct carried in an FInstancedStruct. That is what lets a client send one
to the server — and it means every field in a client-sent transaction, including the containers it
names, is player input. Two gates stand in front of it, and both apply only to requests that
arrived over the wire. Server-side code is never second-guessed.
Is this a transaction a player may even ask for?
Each executor answers with IsClientRequestable(), which defaults to no. Several transactions
are primitives that trust their arguments completely: Insert carries a whole item entry — definition
and stack count included — and AddItem conjures one from an asset id. Reachable from a client those
are item duplication, not features.
| A client may request | Server only |
|---|---|
| Move, Split, Merge, Swap, Sort, Destroy, Transfer, Rotate, Equip, Unequip, Use, Craft, Cook, Trade, Purchase, Sale | Insert, Extract, AddItem, Consume, ConsumeFuel, Damage, Repair, SetItemState |
May this player name these containers?
The access policy decides, per container, with separate Insert and Extract verbs. The default answers "your own, plus whatever you have opened" — see Access Policy Class. This is where sessions earn their keep: opening one on the server is what makes a chest reachable, and closing it takes that back.
A refusal returns EEDTransactionError::NotAuthorized, with a log line naming the actor, the
container and the verb.
The plugin refuses; your game grants. In whatever server-side code handles the interaction, call
OpenSession on the container. Without it a remote client's every drag is refused — correctly,
because nothing has said that player may reach in. A single-player build is unaffected: there the
caller is always the authority, so neither gate applies.
The shipped transactions
| Transaction | What it does |
|---|---|
FEDInsertTransaction | Adds an existing entry, optionally at an address. |
FEDExtractTransaction | Removes one entry. |
FEDMoveTransaction | Moves an entry between containers, preserving handle, state and instance. |
FEDSplitTransaction | Splits N units into a new stack. |
FEDMergeTransaction | Pours one stack into another. |
FEDDestroyTransaction | Removes an entry outright. |
FEDAddItemTransaction | Stack-aware give: tops up existing stacks first, spills into new ones. All-or-nothing. |
FEDConsumeTransaction | Removes N units by definition, smallest piles first. |
FEDTransferTransaction | Moves N units between containers with stack-aware merging. Atomic across both. |
FEDSortTransaction | Consolidates partial stacks, then re-packs. |
FEDSwapTransaction | Trades two entries atomically. |
FEDUseItemTransaction | Uses a usable item (Usable module). |
FEDCraftTransaction | Consumes ingredients and inserts the output, or neither (Crafting module). |
FEDPurchaseTransaction / FEDSaleTransaction | Buy and sell against a currency item (Trading module). |
FEDRotateTransaction | Rotates an item in a grid. |
FEDSetDynamicTagsTransaction | Rewrites one entry's runtime tags. Server-only. |
FEDBatchTransaction | Several transactions as one — see below. Server-only. |
One transaction made of others
Each transaction above is atomic on its own. FEDBatchTransaction adds atomicity across them:
give five items or give none, take the sword and the shield or take neither.
FEDBatchTransaction Batch;
Batch.Steps.Add(FInstancedStruct::Make(TakeCoins));
Batch.Steps.Add(FInstancedStruct::Make(GiveSword));
Container->RunAsServer(FInstancedStruct::Make(Batch)); // SERVER ONLY
The whole batch runs under one mutation journal, so a failure at the fourth step unwinds the first three. Only the first step is validated up front — the second runs against state the first produces, and pre-validating them all would refuse a perfectly good "take the coins, then give the sword".
It is server-only on purpose: a client that could send one could bundle operations the access policy examines one at a time.
Sorting
FEDSortTransaction does the half of "sort" players actually mean first: partial stacks of the
same item are poured together, so eight loose piles of arrows become one. Then the container re-packs
— grids compact row-major with auto-rotation, lists from slot 0.
Only plain, stackable entries with identical state merge. Two half-worn swords and any promoted instance keep their identity.
EEDSortMode covers ByName, ByCategory, ByCount, ByDefinition and ByRarity. A
view model remembers a mode and CycleSortMode advances it, which is the one-button "change ordering"
control inventory mods have trained players to expect.
Failure is a reason, not a boolean
EEDTransactionError names why: NoAuthority, TargetFull, RuleRejected, InvalidPlacement,
InvalidItem, InvalidParameters, NoExecutor, and so on. EDTransactionReasons::ToText turns one
into player-readable text.
That is what EvaluateAddItem returns, so a vendor's Buy button can be greyed out with "There is no
room for that" instead of failing silently when clicked:
const FEDPlacementCheckResult Check = Container->EvaluateAddItem(Definition, Count);
BuyButton->SetIsEnabled(Check.bCanAdd);
Tooltip->SetText(Check.Reason);
EEDTransactionError replicates, so the order of its values is part of the network contract. New
reasons are appended, never inserted.
Two failures mean your project, not your move
Most reasons describe a move that was not possible right now — the bag is full, a rule said no. Two groups describe something that could never have worked:
| Reason | What it means |
|---|---|
NoExecutor | Nothing is registered to perform that transaction type. The module is disabled, its Game Feature is not active yet, or your own executor was never registered with FEDTransactionExecutorRegistry. |
NoAuthority, InvalidParameters, Unknown | The call could not run where it was made, or was malformed. |
These always log at Warning, whether or not
Enable Transaction Logging is on — the person who would switch logging on
is the person who does not yet know there is anything to look at, because the transaction simply did
nothing. Ordinary refusals stay quiet unless you ask for the full trace.
NoExecutor also logs once per transaction type, naming the likely cause. It is a separate reason
precisely because it used to answer Unknown, which reads as an internal error and sends people to
inspect their transaction instead of their module list.
Adding your own
Three pieces:
- A
USTRUCTderiving fromFEDTransaction— data only, no virtuals, because it travels inside anFInstancedStructover RPC. - An executor implementing
IEDTransactionExecutor:Validate,Execute,GetAffectedContainers. The last one is how the processor knows which containers to record, and therefore what it can undo. - A registration, at module startup, in
FEDTransactionExecutorRegistry.
Core dispatches it without knowing your type. That registry is how the feature modules add crafting and trading without touching Core — see Extending.
Where next
- Replication — what a client may believe
- Extending — your own transaction, rule or layout