Skip to main content

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*
WhereAuthority onlyAnywhere
ReturnsWhether it happened, nowNothing; the result arrives later
On a clientReturns false, does nothingTravels to the server
ResultThe return valueOnTransactionResult 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 requestServer only
Move, Split, Merge, Swap, Sort, Destroy, Transfer, Rotate, Equip, Unequip, Use, Craft, Cook, Trade, Purchase, SaleInsert, 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.

Wiring a chest

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​

TransactionWhat it does
FEDInsertTransactionAdds an existing entry, optionally at an address.
FEDExtractTransactionRemoves one entry.
FEDMoveTransactionMoves an entry between containers, preserving handle, state and instance.
FEDSplitTransactionSplits N units into a new stack.
FEDMergeTransactionPours one stack into another.
FEDDestroyTransactionRemoves an entry outright.
FEDAddItemTransactionStack-aware give: tops up existing stacks first, spills into new ones. All-or-nothing.
FEDConsumeTransactionRemoves N units by definition, smallest piles first.
FEDTransferTransactionMoves N units between containers with stack-aware merging. Atomic across both.
FEDSortTransactionConsolidates partial stacks, then re-packs.
FEDSwapTransactionTrades two entries atomically.
FEDUseItemTransactionUses a usable item (Usable module).
FEDCraftTransactionConsumes ingredients and inserts the output, or neither (Crafting module).
FEDPurchaseTransaction / FEDSaleTransactionBuy and sell against a currency item (Trading module).
FEDRotateTransactionRotates an item in a grid.
FEDSetDynamicTagsTransactionRewrites one entry's runtime tags. Server-only.
FEDBatchTransactionSeveral 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);
Enum order is frozen

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:

ReasonWhat it means
NoExecutorNothing 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, UnknownThe 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:

  1. A USTRUCT deriving from FEDTransaction — data only, no virtuals, because it travels inside an FInstancedStruct over RPC.
  2. 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.
  3. 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​