Ledger

Trading

Swap items and gold between two players, all or nothing.

A trade changes two players' data at once. If you do that with two separate calls and the server crashes in between, one player can lose their item without getting paid. Ledger.Tx changes both together: either both changes happen, or neither does.

The ops

Each player in the trade gets one op. Here the seller gives up an item and gets gold, and the buyer does the opposite:

type PlayerOps = {
	SellItem: { Item: string, Price: number },
	BuyItem: { Item: string, Price: number },
}

	Reducer = function(Data, Op)
		if Op.Kind == "SellItem" then
			local Count = Data.Inventory[Op.Item]
			if not Count then
				return nil -- they don't have it
			end

			local Inventory = table.clone(Data.Inventory)
			if Count > 1 then
				Inventory[Op.Item] = Count - 1
			else
				Inventory[Op.Item] = nil
			end

			local New = table.clone(Data)
			New.Gold += Op.Price
			New.Inventory = Inventory
			return New
		end

		if Op.Kind == "BuyItem" then
			if Data.Gold < Op.Price then
				return nil -- not enough gold
			end

			local Inventory = table.clone(Data.Inventory)
			Inventory[Op.Item] = (Inventory[Op.Item] or 0) + 1

			local New = table.clone(Data)
			New.Gold -= Op.Price
			New.Inventory = Inventory
			return New
		end

		return nil
	end,

These are normal ops with normal reducer rules. Each side checks its own player: the seller must have the item, and the buyer must have the gold.

The trade

Put this in a ModuleScript, ServerStorage.Trading:

-- Saves anything waiting for this player, if they're in this server.
local function FlushIfHere(UserId: number)
	local Player = Players:GetPlayerByUserId(UserId)
	if not Player then
		return
	end

	local Session = PlayerStore:Get(Player)
	if not Session then
		return
	end

	Session:Flush()
end

-- Shows this player their latest saved data, if they're in this server.
local function RefreshIfHere(UserId: number)
	local Player = Players:GetPlayerByUserId(UserId)
	if not Player then
		return
	end

	local Session = PlayerStore:Get(Player)
	if not Session then
		return
	end

	Session:Refresh()
end

local function Trade(SellerId: number, BuyerId: number, Item: string, Price: number): boolean
	FlushIfHere(SellerId)
	FlushIfHere(BuyerId)

	local Ok, Result, Info = Ledger.Tx({
		PlayerStore:Leg(SellerId, { Kind = "SellItem", Item = Item, Price = Price }),
		PlayerStore:Leg(BuyerId, { Kind = "BuyItem", Item = Item, Price = Price }),
	})

	if Result == Ledger.Reason.Unresolved and Info and Info.Outcome then
		Ok, Result = Info.Outcome:Wait()
	end

	RefreshIfHere(SellerId)
	RefreshIfHere(BuyerId)

	if Ok then
		return true
	end

	if Result == Ledger.Reason.Refused and Info and Info.Key then
		local SellerSaidNo = tostring(Info.Key.Key) == tostring(SellerId)
		if SellerSaidNo then
			print("The seller doesn't have a", Item)
			return false
		end

		print("The buyer can't afford it")
		return false
	end

	warn("Trade failed:", Result)
	return false
end

Trade takes UserIds, not players. A trade doesn't need either player to be in this server, or online at all: the buyer could be in another server, and the seller could be offline selling from a market listing. Ledger changes their saved data either way.

PlayerStore:Leg(UserId, Op) describes one player's side of the trade. Ledger.Tx takes the list and applies every side, or none.

A two-player trade costs 2 DataStore requests before it answers.

Players in this server

A trade works on the players' saved data, not on what their sessions are showing. So for a player who's in this server:

  • Before: Flush their session, so anything Apply has waiting is saved and the trade sees it.
  • After: Refresh their session, so they see the trade straight away.

FlushIfHere and RefreshIfHere do that, and do nothing for a player who isn't here. They look the session up fresh each time, because a player can leave while the trade runs.

A Flush costs a DataStore request when the player has changes waiting, and nothing when they don't. If your game trades a lot, that adds up. You can skip it, but then a change Apply still had waiting can be undone at the next save if the trade already spent the same gold.

A player in another server sees the trade within about 2 minutes. To show it sooner, send a message and refresh them there, like the gift example. An offline player sees it next time they join.

If that other server still has Apply changes waiting for the player, they're checked again against the traded data when they save. So a player can't spend the same gold in a trade and in another server at once.

Answers

AnswerWhat it means
trueBoth sides happened.
RefusedOne side's reducer said no. Nothing happened to either player. Info.Key.Key is the UserId of the player whose side said no.
UnresolvedLedger doesn't know yet, and keeps going until it does. When it finishes, either both sides happened or neither did. Wait on Info.Outcome if you want to know. In a long outage that can take a while.
SpentThe trade stopped without going through, for a reason that isn't your rules. Nothing happened. It's safe to try again.

Everything else means the same as on Answers, and nothing happened to either player.

Not just players

A leg can be any key in any store, so the same code moves gold from a player into a guild bank, or between three players at once. Each key can only be in a trade once.

Items with their own data

Some items are one of a kind like a serial number or past owners. Don't carry that data in the trade's ops. One op in a trade can be at most 4,096 bytes, and a list of past owners only grows.

Give each item its own key instead, in a store of its own, and keep only the item's id in the player's data:

export type ItemData = {
	Owner: number,
	Wear: number,
	Owners: { number },
}

type ItemOps = {
	ChangeOwner: { From: number, To: number },
}

local ItemStore = Ledger.New<<ItemData, ItemOps>>({
	Name = "Items",
	Keys = "String",
	Default = { Owner = 0, Wear = 0, Owners = {} },
	MustExist = true,
	Erasable = false,
	Reducer = function(Data, Op)
		if Op.Kind == "ChangeOwner" then
			if Data.Owner ~= Op.From then
				return nil -- not theirs to give
			end

			local Owners = table.clone(Data.Owners)
			table.insert(Owners, Op.To)

			local New = table.clone(Data)
			New.Owner = Op.To
			New.Owners = Owners
			return New
		end

		return nil
	end,
})

Trading the item is then one trade with a leg for each player and one for the item. The players' ops carry only the id:

local Ok, Result = Ledger.Tx({
	PlayerStore:Leg(SellerId, { Kind = "RemoveItem", ItemId = ItemId }),
	PlayerStore:Leg(BuyerId, { Kind = "AddItem", ItemId = ItemId }),
	ItemStore:Leg(ItemId, { Kind = "ChangeOwner", From = SellerId, To = BuyerId }),
})

The item's own reducer adds to its history, so the history never travels in an op, however long it gets. To show an item, Peek it by its id.

Each item adds one key to the trade, and how many keys fit depends on their length, so keep item ids short. See Limits.

Next: Shared data

On this page