Repository navigation
api inventory
Audience: developers of Coffee Pub modules that move items or coins between Actors.
Scope: the public surface of blacksmith.inventory - the four mutation primitives, their results, and their error codes.
Mechanism and design rationale live in architecture/architecture-inventory.md.
These are mechanical primitives. They resolve fresh documents, validate, mutate, and return a structured result.
They own no workflow. There are no sockets, no permission checks, no windows, no chat messages, and no notifications. A consumer calls them from its own GM-authoritative handler after its own authorization has passed, so approval, recipient selection, distance checks, and domain rules stay with the module that has them.
Two shapes, because they answer different questions. grantItem, grantItems, and grantCurrency put
content onto an Actor from a compendium, a loot table, a crafting result, or constructed data - there is no
source Actor and nothing is taken from anywhere. transferItem and transferCurrency move content between
two Actors.
Always branch on result.ok. These validate against live documents, so a call can legitimately fail on a
corpse two players are looting at once.
const result = await blacksmith.inventory.grantItem({
targetActorUuid,
itemUuid, // or itemData
quantity: 3,
stack: 'merge',
ignoreFlags: ['coffee-pub-squire.isNew'],
flags: { 'coffee-pub-squire': { isNew: true } }
});
// { ok: true, targetItemId, quantity, merged }| Option | Required | Meaning |
|---|---|---|
targetActorUuid |
yes | Accepts a synthetic token-actor UUID (Scene.x.Token.y.Actor.z). |
itemUuid |
one of | Anything fromUuid resolves: compendium, world, or actor-embedded item. |
itemData |
one of | A raw item object, for something assembled in memory. Used only when itemUuid is absent. |
quantity |
no | Defaults to the source item's own quantity. Must be a positive integer, and is not capped by it. |
stack |
no |
'merge' (default) or 'separate'. |
ignoreFlags |
no | Flag paths the merge check treats as non-identity. |
flags |
no | Flags written in the same operation as the item. See below - this is not a convenience. |
container |
no | Id of a container on the target Actor. Omitted or null lands the item at the root of the inventory. |
When itemUuid resolves a compendium document, the created item carries _stats.compendiumSource, so
granted items keep provenance that a hand-rolled toObject() create loses. This never splits a stack; see
the merge rules.
const result = await blacksmith.inventory.grantItems({
targetActorUuid,
items: [
{ itemUuid, quantity: 1, flags },
{ itemUuid, quantity: 6 }
],
stack: 'merge',
ignoreFlags: []
});
// { ok: true, results: [ { ok, targetItemId, quantity, merged }, ... ] }results is index-aligned with items, and each entry has the same shape as a grantItem result. Top-level
ok is true only when every entry succeeded; individual entries can fail independently, so check them rather
than the top-level flag alone.
Duplicate entries in one batch coalesce into a single row. Three entries for the same item produce one
row holding the summed quantity, and all three results report that row's targetItemId. The entries after
the first carry coalesced: true with merged: false - nothing that already existed was grown, so calling
it a merge would be wrong, but neither did they create a row of their own.
Merges into rows that already existed are applied as one batched update, and creates as one batched create, so a batch of any size costs at most two writes to the target Actor rather than one per item.
Use this instead of looping grantItem. It is not a convenience wrapper. Everything that can be created
goes in a single createEmbeddedDocuments and every merge in a single updateEmbeddedDocuments, which
matters for the reason described under flags. For taking items off another Actor, use transferItems.
Calling this once per item defeats the whole point. A loop of grantItems({items: [oneThing]}) is
exactly as many writes as a loop of grantItem, and duplicate items cannot coalesce because their payloads
never meet in the same call. Accumulate everything first, then make one call. This has already been shipped
by mistake once - the plural name made a per-item loop look like batching.
const result = await blacksmith.inventory.transferItem({
sourceActorUuid,
targetActorUuid,
itemId,
quantity: 3,
stack: 'merge',
ignoreFlags: [],
flags: {}
});
// { ok: true, sourceItemId, targetItemId, quantity, sourceRemaining, sourceDeleted, merged }itemId is the embedded item id on the source Actor. The item is resolved and its quantity re-checked
against the live document inside the lock, so a stale client-side quantity cannot create more on the target
than the source holds.
Take All. Moves several items from one Actor to another in one operation.
const result = await blacksmith.inventory.transferItems({
sourceActorUuid,
targetActorUuid,
items: [
{ itemId: arrowsId, quantity: 5 }, // partial
{ itemId: daggerId }, // whole stack
{ itemId: bagId } // refused if packed; other rows still move
],
stack: 'merge',
ignoreFlags: []
});
// { ok, results: [ { ok, targetItemId, quantity, merged, sourceItemId, sourceRemaining, sourceDeleted }, ... ] }Use this rather than looping transferItem. It is not sugar. N single transfers are N writes to the
recipient and N to the source; this is at most two writes per Actor whatever the item count, which is the
difference between one encumbrance recompute per Actor and N racing ones.
results is index-aligned with items and each entry has the same shape as a transferItem result, plus
itemId on failures so a caller can attribute a refusal to a row. Validation is per item, not
all-or-nothing: one packed container on a corpse does not stop the other rows being taken. Top-level ok is
true only when every entry succeeded, so check the entries.
Duplicate entries coalesce on the target the same way grantItems does, so two rows of the same arrows land
as one stack.
The same itemId twice in one call is refused with DUPLICATE_ITEM on the second entry. Two entries for
one item make the per-entry quantity checks meaningless - each validates against the full stack, and together
they could over-draw it. Summing them would be guessing at intent the caller can state exactly.
If the source reduction fails after the target has been written, the whole grant is reversed: created rows
deleted, merged rows decremented by exactly what was added. Every entry then reports SOURCE_UPDATE_FAILED,
or ROLLBACK_FAILED if the reversal itself failed.
Every entry is validated against the state at the start of the call, before anything moves. That is what makes per-item results coherent, and it has one consequence worth stating plainly because it is invisible from the outside.
A container and its contents cannot be emptied and taken in the same call. Send a bag and the items inside
it together and the bag is still packed as far as that call is concerned - even though the same call is what
empties it. The contents move, the bag comes back CONTAINER_HAS_CONTENTS, and the source is left holding an
empty bag.
That is the documented behaviour working correctly, and it still looks like a bug from the table: a Loot All that clears a body leaves a row of now-empty sacks on it.
A consumer clearing a container hierarchy has to loop. Take everything that is not currently a packed container, then call again - the next pass picks up whatever the previous one emptied, and nested bags resolve the same way without special handling. Bound the loop and stop as soon as a pass moves nothing, so a genuinely stuck row is reported as left behind rather than retried forever. Curator's loot window does exactly this, capped at four passes.
This is stated because the failure is silent and the correct reading is not obvious. A consumer that
implements a single pass sees empty containers left behind and reasonably concludes transferItems is dropping
rows. It is not; it is answering the question it was asked, at the moment it was asked.
await blacksmith.inventory.grantCurrency({
targetActorUuid,
currency: { gp: 12 }
});
// { ok: true, currency: { gp: 12 } }
await blacksmith.inventory.transferCurrency({
sourceActorUuid,
targetActorUuid,
currency: { cp: 10, sp: 4, gp: 2 }
});
// { ok: true, currency: { cp: 10, sp: 4, gp: 2 } }Amounts are deltas to move, never absolute totals. Denominations are pp, gp, ep, sp, cp, and
each amount must be a non-negative integer.
The one currency method that takes absolute values. No counterparty, no affordability check.
await blacksmith.inventory.setCurrency({
targetActorUuid: shopUuid,
currency: { gp: 250 }
});
// { ok: true, currency: { gp: 250 }, previous: { gp: 40 } }For a GM editing an Actor's own purse - stocking a shop's till, correcting a mistake - rather than for
anything with two parties. previous reports what each named denomination held before the write, which is
what a GM needs to undo it.
Only the denominations you name are written. Omitting silver leaves silver alone; it is not zeroed.
Zero is a meaningful value here, unlike in a delta, so { gp: 0 } empties the gold.
Use this rather than writing system.currency directly. A raw actor.update() takes no lock, so it can
land between another operation's read and its write and be silently discarded - the settlement writes
stale + delta over the top. Absolute values are safe here for the reason deltas exist elsewhere: the
danger is a total computed from a stale read outside a lock, and a value the GM typed has no read to go
stale.
Like every primitive here it performs no permission check. Call it from a GM-authoritative handler after your own authorization has passed.
Denominations are never converted. Moving 2 gp from a purse holding only 20 sp returns
INSUFFICIENT_CURRENCY. Automatic exchange is a house rule, not a mechanic, and imposing one would make a
primitive decide something that belongs to a table.
transferCurrency validates every denomination before writing anything, because a partially completed
currency move is not something a rollback expresses cleanly.
Several directed transfers settled as one operation. Everything commits, or nothing does.
const result = await blacksmith.inventory.exchange({
transfers: [
{ from: merchantUuid, to: recipientUuid, items: [{ itemId, quantity: 1 }], container: shelfId },
{ from: payerUuid, to: merchantUuid, currency: { gp: 25 } },
{ from: merchantUuid, to: payerUuid, currency: { gp: 5 } }
]
});
// { ok: true, results: [ { ok, from, to, items: [...], currency: {...} }, ... ] }A transfer is directed: from and to are Actor UUIDs, and it carries items, currency, or both.
Selling is the same call with the sides swapped. Two-party is simply the two-transfer case.
Transfers are directed rather than sided because a shop transaction is routinely three-party - the shopper pays, so buying for someone else sends goods to a recipient, coin from a payer, and change back to the payer. A two-sided shape cannot say where anything goes once there are three parties.
| Option on a transfer | Meaning |
|---|---|
items |
[{ itemId, quantity?, flags? }], drawn from from. |
currency |
Deltas moved from from to to. |
container |
Arrival container on to, as for grantItem. |
copy |
The target receives the items and the source is not touched. |
preserveEmptySource |
An emptied stackable row stays at quantity 0 instead of being deleted. |
stack and ignoreFlags are call-level and apply to every arrival.
This is all-or-nothing, unlike transferItems. That method reports per item because one packed bag on
a corpse must not stop the other rows; here a partly applied settlement is the thing being prevented. Any
refusal fails the whole call and writes nothing. A failure result is a single { ok: false, code, index, ... }
naming the leg at fault, with entryIndex when a specific item is the cause - not a per-transfer array.
These answer two different stock models and are not interchangeable.
copy is for stock that has no count: the source row is a template and a sale hands over a duplicate.
Because there is no count, the source stack is not a ceiling - a shop can sell three from a row that
reads one, and INSUFFICIENT_QUANTITY cannot arise. The source is not written at all, so the result
reports sourceRemaining: null and sourceDeleted: false.
preserveEmptySource is for stock that is a count in system.quantity. The source genuinely loses what
moves; the only change is that emptying the stack leaves the row at 0 rather than deleting it, so a shop
goes out of stock rather than off the shelf. It has no effect on an item without a quantity, which has no
zero to sit at.
Everything is checked against the state at the start of the call, the same rule transferItems states.
Change arriving in the same settlement cannot fund the payment - the payer must actually hold what they
hand over.
Payment and change between the same pair are never netted. Affordability is judged on the total each Actor pays out; only the resulting write is combined, which is arithmetic rather than a relaxation.
from === to is refused per transfer with SAME_ACTOR. An Actor appearing in several transfers is normal
and expected - the merchant sends goods and receives coin in one settlement.
The same itemId drawn twice fails with DUPLICATE_ITEM, for the reason transferItems gives. Two copy
legs may name the same source freely, since neither draws it down.
An empty transfers array fails with EXCHANGE_EMPTY.
Every actorUuid accepts a world Actor, a Token, or a synthetic token actor. All three are supported,
not merely tolerated:
| Form | Example | Resolves to |
|---|---|---|
| World actor | Actor.abc |
that Actor |
| Token | Scene.s.Token.t |
the token's actor |
| Synthetic token actor | Scene.s.Token.t.Actor.a |
that unlinked token's own actor |
The synthetic form is the ordinary case, not the edge one -- a corpse on the canvas is always an unlinked token, and so is a merchant dragged onto a map.
Two unlinked tokens sharing a base Actor are two independent targets, and that is correct. Each has its own ActorDelta and therefore its own actor UUID, so granting to one does not touch the other. Two copies of the same travelling salesman are two shops, and two players can trade with them simultaneously.
Locks key on the RESOLVED actor, never on the string you passed. That is what makes the aliases safe:
handing in Scene.s.Token.t and Actor.a for the same linked token takes one lock, not two, because both
resolve to the same document. The same resolution is what lets SAME_ACTOR refuse a transfer expressed as
two different UUIDs for one actor.
Every one of these writes system.container on arrival. Without a container option the item lands at
the root of the target's inventory; with one it lands inside that container. The source item's own
containment is never carried over - it names a row on the source Actor, which does not exist on the
target.
grantItems and transferItems take container at the top level as a default for the batch, and each
entry may carry its own. An entry stating container: null lands at root even when the batch names one;
an entry that omits the key takes the batch default.
An id that does not resolve to a container on the target Actor is refused with CONTAINER_NOT_FOUND
rather than quietly falling back to root, because a caller that names a container has a reason and a
silent fallback puts the item somewhere the result does not mention. Nesting deeper than dnd5e permits is
refused with CONTAINER_MAX_DEPTH. In the batch forms both are per entry, so one bad id does not stop the
other rows.
Containment participates in merge identity. Two otherwise identical stacks in different containers are in different places and stay separate rows; a grant naming a container merges only with a matching row already in that container.
quantity on grantItem and grantItems may exceed the resolved document's own system.quantity.
Nothing is drawn down, so that number is a property of the document rather than a count of stock - an SRD
crowbar reads 1 because that is what one crowbar is. INSUFFICIENT_QUANTITY is unreachable from a grant,
exactly as it is from an exchange copy leg.
INVALID_QUANTITY still applies: a quantity must be a positive integer, and cannot exceed 1 for an item
whose schema carries no quantity at all. That describes what the document is rather than how much of it
exists, which is the distinction the two codes divide on.
Transfers are unchanged. There the source stack is a live count that the operation reduces, so asking for
more than it holds is a real error and still returns INSUFFICIENT_QUANTITY.
There is no hasQuantity parameter and there will not be one. Whether an item stacks decides
delete-the-whole-item versus decrement, and a wrong value from a caller destroys a stack. The API reads
system.quantity off the resolved document and ignores any caller claim about it.
Pass flags rather than setting them afterwards with setFlag. A follow-up write to the same Actor
reintroduces a live dnd5e bug: encumbrance is recomputed on every item create, update, and delete as a
check-then-create against one fixed effect id with no lock, and Foundry does not await that recompute from the
promise createEmbeddedDocuments returns. Two sequential, individually awaited writes to one Actor therefore
both try to create the same effect id and the server rejects the second:
Error: The _id [dnd5eencumbered0] already exists within the parent collection: Actor [...] effects
Awaiting correctly does not avoid this, and neither does the API's own locking, because the recompute
completes outside the critical section. One write is the only fix available to anyone. This is also why
grantItems exists rather than a loop.
The recompute also runs on an Actor's own update, not only on item writes, so an actor.update() followed
closely by an item write collides the same way. If you see this error while using this API, something is
making a second write to that Actor - the API itself makes one per Actor per call.
The flags are folded into the create payload on the create branch, and into the same update as the quantity change on the merge branch. On a merge, only the flags you passed are written - the incoming document's own flag set is not applied to the row that already existed.
flags and ignoreFlags compose rather than compete. Declaring the same key in both is correct: transient UI
state is identity-irrelevant and arrival-relevant at the same time.
Flag paths dropped from an arriving item before it is written. Available on grantItem, grantItems,
transferItem, transferItems, and exchange, all at call level.
await blacksmith.inventory.exchange({
transfers: [...],
omitFlags: ['coffee-pub-merchant.par']
});For state that describes where an item came from rather than what it is. A shop stamping shelf data on its stock leaks that data to every buyer, where it means nothing - the same shape as a container id naming a row the recipient does not have.
Paths are removed before your flags are merged in, so naming the same path in both is coherent: the
omission clears what rode along from the source and the addition writes what you meant to arrive.
omitFlags and ignoreFlags are separate and often want to travel together. omitFlags changes the
payload; ignoreFlags changes the merge comparison. An arrival stripped of a path still will not merge with
a row on the target that carries it - which is exactly what rows created before you started omitting look
like - so pass the path in both while those rows still exist.
Only omit what you wrote. A flag another module owns may be identity-bearing to it, and stripping it makes that module's merges depend on which of you moved the item.
If your module writes a flag to items AFTER they are created, declare it once during ready:
blacksmith.inventory.registerTransientFlag('coffee-pub-squire.isNew');
blacksmith.inventory.getTransientFlags(); // diagnosticsEvery merge comparison then ignores that path, for every caller. ignoreFlags is unioned with this
registry.
Declare it if you write it, because no consumer can declare it for you. A module that stamps a flag on
items other modules created makes those modules' merges depend on whether its write has landed yet - two
identical items merge or do not merge according to timing. The consumer calling grantItem has no way to
know your module does this, so the obligation sits with the writer.
Declare a flag if you write it after creation and it does not describe what the item is. A "recently arrived" badge is transient. A crafting quirk or a skill level is not - those are identity, and excluding them would merge items that genuinely differ.
With stack: 'merge' (the default) an incoming item is added to a matching row on the target instead of
creating a second one. A second row is not a neutral outcome - it quietly breaks anything holding an item id,
including favourites lists, tracked light sources, and container membership.
An incoming item merges only when all of the following hold. If any fails, it lands as a separate row and
merged is false; a failed merge check never fails the operation.
- Same
nameand sametype, and the type is transferable. - Both carry a numeric
system.quantity. - Deep-equal
systemsource data, excludingquantityand the reset set. - Deep-equal
flags, excluding the paths inignoreFlags. - Neither side has applied enchantments.
-
_stats.compendiumSourcedoes not contradict: a missing source on either side is treated as unknown, and only two present-but-different sources block a merge. -
system.identifierdoes not contradict, under the same one-sided rule. dnd5e writes it from the item's name during creation, so a stored row carries a slug and a payload supplied as plainitemDatacarries nothing; an absent identifier is treated as unknown. Names must already match, so only two present-but-different identifiers block a merge.
A merge bumps quantity and writes any arrival flags. It does not adopt the incoming item's
compendiumSource, so a row keeps whatever provenance it was created with. One consequence follows from that
plus the rule above: an unsourced row will merge with items from any source, because each of those is another
one-sided comparison. The stack's provenance stays unknown, which is what it already was, and the items are
identical in every respect the predicate checks.
Comparison is on source data, not the prepared model, so derived values like uses.value do not spuriously
prevent a merge. Anything undeclared in ignoreFlags counts as identity, which is why a module writing UI
state to item flags must declare those keys.
Comparison changed; stored rows did not. The predicate decides what merges from here on, and it cannot
reach items already sitting in a world. A flag written into rows before your module adopted omitFlags is
still on those rows, so an arrival without it still compares as different and still stacks separately. That
tail is what ignoreFlags is for, and an ignoreFlags entry naming your own flag is as likely to be a
migration as a workaround. Do not remove one on the strength of a predicate fix -- how long it stays is
a judgement about how much old data is left, not about this code.
Both sides are completed through the item type's data model before comparison, so a payload you supply as
plain itemData is compared as the row it will become rather than as the object you passed. Omitting
rarity or description from itemData therefore does not prevent a merge with a row that carries the
schema defaults for them, because the row your payload creates would carry those same defaults.
system.properties participates, but only across values dnd5e recognises for that item type
(CONFIG.DND5E.validProperties). mgc is compared, so a magical item does not merge into a mundane one; an
unrecognised entry is ignored on both sides, because it is not a property the system acknowledges and it does
not survive item creation -- so comparing it would compare a shape that never reaches storage.
stack: 'separate' always creates a new row.
Non-physical item types. Only weapon, equipment, consumable, tool, loot, and container move.
Anything else returns ITEM_NOT_TRANSFERABLE. A raw create of a class, feat, or spell bypasses dnd5e's
singleton check and its advancement flow.
A container that is not empty. Returns CONTAINER_HAS_CONTENTS with contentCount, so a consumer can
say how many items to unpack first. An empty container transfers normally. When emptiness cannot be
determined, the container is refused and contentCount is null - an unverifiable container is treated as
packed, because dnd5e stores containment on the child and a wrong answer orphans the contents silently.
Same-actor transfers. SAME_ACTOR. This also excludes moving an item between containers on one Actor,
which is a legitimate dnd5e operation this API does not currently express.
Every failure is { ok: false, code, ...context }.
| Code | Meaning |
|---|---|
SOURCE_ACTOR_NOT_FOUND / TARGET_ACTOR_NOT_FOUND
|
The UUID did not resolve to an Actor. |
SOURCE_ITEM_NOT_FOUND |
No item with that id on the source Actor. |
ITEM_NOT_FOUND |
itemUuid did not resolve, or neither itemUuid nor itemData was supplied. |
SAME_ACTOR |
Source and target are the same Actor. |
INVALID_QUANTITY |
Not a positive integer, or above 1 for a non-stacking item. |
INSUFFICIENT_QUANTITY |
Carries requested and available. Transfers only; unreachable from a grant or an exchange copy leg. |
INVALID_CURRENCY |
Unknown denomination, negative or non-integer amount, or nothing positive to move. |
INSUFFICIENT_CURRENCY |
Carries shortfalls keyed by denomination, each with requested and available. |
ITEM_NOT_TRANSFERABLE |
Carries type and allowed. |
CONTAINER_HAS_CONTENTS |
Carries contentCount, or null when it could not be determined. |
CONTAINER_NOT_FOUND |
The container id did not resolve to a container on the target Actor. Carries containerId, and type when the id resolved to something that is not a container. |
CONTAINER_MAX_DEPTH |
Placing the item there would nest deeper than dnd5e allows. Carries containerId, depth, and max. |
TARGET_CREATE_FAILED |
The write to the target failed. Nothing was taken from the source. |
SOURCE_UPDATE_FAILED |
The target received the item but the source could not be reduced. The grant was rolled back. |
ROLLBACK_FAILED |
As above, and the rollback also failed. Requires manual repair. |
LOCK_TIMEOUT |
Another operation held the Actor too long. Carries actorUuid and waitedMs. |
DUPLICATE_ITEM |
The same itemId was drawn twice in one call (transferItems, or exchange across legs). |
EXCHANGE_EMPTY |
exchange was called with no transfers. |
SOURCE_UPDATE_FAILED and ROLLBACK_FAILED also carry targetItemId, merged, quantity,
observedTargetQuantity, and observedSourceQuantity. Surface these rather than swallowing them: whether
the row was created or grown, and by how much, is what a GM needs to repair the state by hand.
LOCK_TIMEOUT is the only retryable code. Every other code describes a state that will not change by
trying again, so offering a retry on those misleads the user.
Each primitive locks every Actor it writes for the whole operation, so a Take All from one corpse on two clients cannot double-spend a stack. Two Actors are locked in a deterministic order, so simultaneous opposite-direction transfers cannot deadlock.
The lock is per-client and in memory. It serialises calls made through this API on one client; it is not a distributed lock and does not coordinate two GM clients. Consumers already route mutations through a single GM-authoritative handler, which is what makes that sufficient.
- Ai assistance
- Applicationv2 traps
- CSS traps
- dnd5e conditions
- Documentation standard
- Token actor identity
- Campaign
- Canvas
- Chatcards
- Compendiums
- Contextmenu
- Core
- Create journal entry
- Dialog
- Active Effects
- Entity list
- Equiplocations
- Geography
- GM request
- Gmnotes
- Health
- Hookmanager
- Importer
- Inventory
- Menubar
- Notes
- Party
- Pins
- Quantity split
- Requestroll
- Rolls
- Scene config
- Sockets
- Stats
- Tags
- Toast
- Tokens
- Toolbar
- Window
- Worldclock