mirror of
				https://gitea.your-land.de/Sokomine/yl_speak_up.git
				synced 2025-10-31 04:23:03 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			417 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Lua
		
	
	
	
	
	
			
		
		
	
	
			417 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Lua
		
	
	
	
	
	
| 
 | |
| -- buy-button-based trading from trade list: one item(stack) for another item(stack)
 | |
| 
 | |
| -- helper function
 | |
| yl_speak_up.get_trade_item_desc = function(item)
 | |
| 	local stack = ItemStack(item)
 | |
| 	local def = minetest.registered_items[stack:get_name()]
 | |
| 	if(def and def.description) then
 | |
| 		return minetest.formspec_escape(tostring(stack:get_count()).."x "..def.description)
 | |
| 	end
 | |
| 	return minetest.formspec_escape(tostring(stack:get_count()).."x "..stack:get_name())
 | |
| end
 | |
| 
 | |
| 
 | |
| -- helper function; returns how often a trade can be done
 | |
| -- 	stock_buy	how much of the buy stack does the NPC have in storage?
 | |
| -- 	stock_pay	how much of the price stack does the NPC have in storage?
 | |
| -- 	buy_stack	stack containing the item the NPC sells
 | |
| -- 	pay_stack	stack containing the price for said item
 | |
| -- 	min_storage	how many items of the buy stack items shall the NPC keep?
 | |
| -- 	max_storage	how many items of the pay stack items can the NPC accept?
 | |
| yl_speak_up.get_trade_amount_available = function(stock_buy, stock_pay, buy_stack, pay_stack, min_storage, max_storage)
 | |
| 	local stock = 0
 | |
| 	-- the NPC shall not sell more than this
 | |
| 	if(min_storage and min_storage > 0) then
 | |
| 		stock_buy = math.max(0, stock_buy - min_storage)
 | |
| 	end
 | |
| 	stock = math.floor(stock_buy / buy_stack:get_count())
 | |
| 	-- the NPC shall not buy more than this
 | |
| 	if(max_storage and max_storage < 10000) then
 | |
| 		stock_pay = math.min(max_storage - stock_pay, 10000)
 | |
| 		stock = math.min(stock, math.floor(stock_pay / pay_stack:get_count()))
 | |
| 	end
 | |
| 	return stock
 | |
| end
 | |
| 
 | |
| 
 | |
| 
 | |
| -- helper function; also used by fs_trade_list.lua
 | |
| yl_speak_up.get_sorted_trade_id_list = function(dialog, show_dialog_option_trades)
 | |
| 	-- make sure all fields exist
 | |
| 	yl_speak_up.setup_trade_limits(dialog)
 | |
| 	local keys = {}
 | |
| 	if(show_dialog_option_trades) then
 | |
| 		for k, v in pairs(dialog.trades) do
 | |
| 			if(k ~= "limits" and k ~= "" and v.d_id) then
 | |
| 				table.insert(keys, k)
 | |
| 			end
 | |
| 		end
 | |
| 	else
 | |
| 		for k, v in pairs(dialog.trades) do
 | |
| 			if(k ~= "limits" and k ~= "") then
 | |
| 				-- structure of the indices: sell name amount for name amount
 | |
| 				local parts = string.split(k, " ")
 | |
| 				if(parts and #parts == 6 and parts[4] == "for"
 | |
| 				  and v.pay and v.pay[1] ~= "" and v.pay[1] == parts[5].." "..parts[6]
 | |
| 				  and v.buy and v.buy[1] ~= "" and v.buy[1] == parts[2].." "..parts[3]
 | |
| 				  and minetest.registered_items[parts[5]]
 | |
| 				  and minetest.registered_items[parts[2]]
 | |
| 				  and tonumber(parts[6]) > 0
 | |
| 				  and tonumber(parts[3]) > 0) then
 | |
| 					table.insert(keys, k)
 | |
| 				end
 | |
| 			end
 | |
| 		end
 | |
| 	end
 | |
| 	table.sort(keys)
 | |
| 	return keys
 | |
| end
 | |
| 
 | |
| 
 | |
| yl_speak_up.input_trade_via_buy_button = function(player, formname, fields)
 | |
| 	local pname = player:get_player_name()
 | |
| 
 | |
| 	if(fields.buy_directly) then
 | |
| 		local trade_id = yl_speak_up.speak_to[pname].trade_id
 | |
| 		local res = yl_speak_up.check_trade_via_buy_button(player, trade_id, true)
 | |
| 
 | |
| 		if(res.msg ~= "OK") then
 | |
| 			yl_speak_up.show_fs(player, "msg", {
 | |
| 				input_to = "yl_speak_up:trade_via_buy_button",
 | |
| 				formspec = "size[6,2.5]"..
 | |
| 					"label[0.2,-0.2;"..res.msg.."\nTrade aborted.]"..
 | |
| 					"button[2,1.5;1,0.9;back_from_error_msg;Back]"})
 | |
| 			return
 | |
| 		end
 | |
| 		yl_speak_up.show_fs(player, "trade_via_buy_button", trade_id)
 | |
| 		return
 | |
| 	end
 | |
| 
 | |
| 	-- scroll through the trades with prev/next buttons
 | |
| 	if(fields.prev_trade or fields.next_trade) then
 | |
| 		local pname = player:get_player_name()
 | |
| 		local n_id = yl_speak_up.speak_to[pname].n_id
 | |
| 		local dialog = yl_speak_up.speak_to[pname].dialog
 | |
| 		local keys = yl_speak_up.speak_to[pname].trade_id_list or {}
 | |
| 		local trade_id = yl_speak_up.speak_to[pname].trade_id
 | |
| 		local idx = math.max(1, table.indexof(keys, trade_id))
 | |
| 		if(fields.prev_trade) then
 | |
| 			idx = idx - 1
 | |
| 		elseif(fields.next_trade) then
 | |
| 			idx = idx + 1
 | |
| 		end
 | |
| 		if(idx > #keys) then
 | |
| 			idx = 1
 | |
| 		elseif(idx < 1) then
 | |
| 			idx = #keys
 | |
| 		end
 | |
| 		yl_speak_up.speak_to[pname].trade_id = keys[idx]
 | |
| 		-- this is another trade; count from 0 again
 | |
| 		yl_speak_up.speak_to[pname].trade_done = nil
 | |
| 	end
 | |
| 
 | |
| 	if(fields.delete_trade_via_buy_button) then
 | |
| 		local trade_id = yl_speak_up.speak_to[pname].trade_id
 | |
| 		yl_speak_up.delete_trade_simple(player, trade_id)
 | |
| 		return
 | |
| 	end
 | |
| 
 | |
| 	-- the owner wants to go back to the trade list from a dialog trade (action) view
 | |
| 	if(fields.back_to_trade_list_dialog_trade) then
 | |
| 		yl_speak_up.show_fs(player, "trade_list", true)
 | |
| 		return
 | |
| 	-- a dialog trade (action) was displayed; go back to the corresponding dialog
 | |
| 	elseif(fields.back_to_dialog) then
 | |
| 		local pname = player:get_player_name()
 | |
| 		local n_id = yl_speak_up.speak_to[pname].n_id
 | |
| 		local dialog = yl_speak_up.speak_to[pname].dialog
 | |
| 		local trade_id = yl_speak_up.speak_to[pname].trade_id
 | |
| 		local new_d_id = dialog.trades[ trade_id ].d_id
 | |
| 		yl_speak_up.speak_to[pname].d_id = new_d_id
 | |
| 		yl_speak_up.speak_to[pname].trade_list = {}
 | |
| 		yl_speak_up.show_fs(player, "talk") -- TODO parameters
 | |
| 		return
 | |
| 	-- show the trade list
 | |
| 	elseif(fields.back_to_trade_list or fields.quit
 | |
| 	  or not(yl_speak_up.speak_to[pname].trade_id)) then
 | |
| 		yl_speak_up.show_fs(player, "trade_list")
 | |
| 		return
 | |
| 	end
 | |
| 
 | |
| 	-- show the trade formspec again
 | |
| 	yl_speak_up.show_fs(player, "trade_via_buy_button", yl_speak_up.speak_to[pname].trade_id)
 | |
| end
 | |
| 
 | |
| 
 | |
| -- helper function
 | |
| -- if do_trade is false: check only if the trade would be possible and return
 | |
| -- 	error message if not; return "OK" when trade is possible
 | |
| -- if do_trade is true: if possible, execute the trade; return the same as above
 | |
| -- also returns how many times the trade could be done (stock= ..)
 | |
| yl_speak_up.check_trade_via_buy_button = function(player, trade_id, do_trade)
 | |
| 	local pname = player:get_player_name()
 | |
| 	local n_id = yl_speak_up.speak_to[pname].n_id
 | |
| 	local dialog = yl_speak_up.speak_to[pname].dialog
 | |
| 	-- make sure all necessary table entries exist
 | |
| 	yl_speak_up.setup_trade_limits(dialog)
 | |
| 	local this_trade = dialog.trades[trade_id]
 | |
| 	-- the players' inventory
 | |
| 	local player_inv = player:get_inventory()
 | |
| 	-- the NPCs' inventory
 | |
| 	local npc_inv = minetest.get_inventory({type="detached", name="yl_speak_up_npc_"..tostring(n_id)})
 | |
| 	if(not(this_trade)) then
 | |
| 		return {msg = "Trade not found.", stock=0}
 | |
| 	elseif(not(player_inv)) then
 | |
| 		return {msg = "Couldn't find player's inventory.", stock=0}
 | |
| 	elseif(not(npc_inv)) then
 | |
| 		return {msg = "Couldn't find the NPC's inventory.", stock=0}
 | |
| 	end
 | |
| 	-- store which trade we're doing
 | |
| 	yl_speak_up.speak_to[pname].trade_id = trade_id
 | |
| 
 | |
| 	-- what the player pays to the npc:
 | |
| 	local pay_stack = ItemStack(dialog.trades[ trade_id ].pay[1])
 | |
| 	-- what the npc sells and the player buys:
 | |
| 	local buy_stack = ItemStack(dialog.trades[ trade_id ].buy[1])
 | |
| 	local npc_name = minetest.formspec_escape(dialog.n_npc)
 | |
| 
 | |
| 	-- can the NPC provide his part?
 | |
| 	if(not(npc_inv:contains_item("npc_main", buy_stack))) then
 | |
| 		return {msg = "Out of stock", stock = 0}
 | |
| 		-- return {msg = "Sorry. "..npc_name.." ran out of stock.\nPlease come back later.", stock=0}
 | |
| 	-- has the NPC room for the payment?
 | |
| 	elseif(not(npc_inv:room_for_item("npc_main", pay_stack))) then
 | |
| 		return {npc_name.." has no room left!", stock = 0}
 | |
| --		return "Sorry. "..npc_name.." ran out of inventory space.\n"..
 | |
| --			"There is no room to store your payment!"
 | |
| 	end
 | |
| 
 | |
| 	local counted_npc_inv = yl_speak_up.count_npc_inv(n_id)
 | |
| 	local stock_pay = counted_npc_inv[ pay_stack:get_name() ] or 0
 | |
| 	local stock_buy = counted_npc_inv[ buy_stack:get_name() ] or 0
 | |
| 	-- are there any limits which we have to take into account?
 | |
| 	local min_storage = dialog.trades.limits.sell_if_more[ buy_stack:get_name() ]
 | |
| 	local max_storage = dialog.trades.limits.buy_if_less[  pay_stack:get_name() ]
 | |
| 	if((min_storage and min_storage > 0)
 | |
| 	  or (max_storage and max_storage < 10000)) then
 | |
| 		-- trade limit: is enough left after the player buys the item?
 | |
| 		if(    min_storage and min_storage > stock_buy - buy_stack:get_count()) then
 | |
| --			return "Stock too low. Only "..tostring(stock_buy)..
 | |
| --				" left, want to keep "..tostring(min_storage).."."
 | |
| 			return {msg = "Sorry. "..npc_name.." currently does not want to\nsell that much."..
 | |
| 				" Current stock: "..tostring(stock_buy)..
 | |
| 				" (min: "..tostring(min_storage).."). Perhaps later?",
 | |
| 				stock = 0}
 | |
| 		-- trade limit: make sure the bought amount does not exceed the desired maximum
 | |
| 		elseif(max_storage and max_storage < stock_pay + pay_stack:get_count()) then
 | |
| 			return {msg = "Sorry. "..npc_name.." currently does not want to\n"..
 | |
| 				"buy that much."..
 | |
| 				" Current stock: "..tostring(stock_pay)..
 | |
| 				" (max: "..tostring(max_storage).."). Perhaps later?",
 | |
| 				stock = 0}
 | |
| 		end
 | |
| 		-- the NPC shall not sell more than this
 | |
| 		if(min_storage and min_storage > 0) then
 | |
| 			stock_buy = math.max(0, stock_buy - min_storage)
 | |
| 		end
 | |
| 	end
 | |
| 	-- how often can this trade be done?
 | |
| 	local stock = yl_speak_up.get_trade_amount_available(
 | |
| 				stock_buy, stock_pay,
 | |
| 				buy_stack, pay_stack,
 | |
| 				min_storage, max_storage)
 | |
| 	-- can the player pay?
 | |
| 	if(not(player_inv:contains_item("main", pay_stack))) then
 | |
| 		-- both slots will remain empty
 | |
| 		return {msg = "You can't pay the price.", stock = stock}
 | |
| 	elseif(not(player_inv:room_for_item("main", buy_stack))) then
 | |
| 		-- the player has no room for the sold item; give a warning
 | |
| 		return {msg = "You don't have enough free inventory\nspace to store your purchase.",
 | |
| 			stock = stock}
 | |
| 	end
 | |
| 
 | |
| 	-- was it a dry run to check if the trade is possible?
 | |
| 	if(not(do_trade)) then
 | |
| 		return {msg = "OK", stock = stock}
 | |
| 	end
 | |
| 
 | |
| 	-- actually do the trade
 | |
| 	local payment = player_inv:remove_item("main",  pay_stack)
 | |
| 	local sold    = npc_inv:remove_item("npc_main", buy_stack)
 | |
| 	-- used items cannot be sold as there is no fair way to indicate how
 | |
| 	-- much they are used
 | |
| 	if(payment:get_wear() > 0 or sold:get_wear() > 0) then
 | |
| 		-- revert the trade
 | |
| 		player_inv:add_item("main", payment)
 | |
| 		npc_inv:add_item("npc_main", sold)
 | |
| 		return {msg  = "Sorry. "..npc_name.." accepts only undammaged items.", stock = stock}
 | |
| 	end
 | |
| 	player_inv:add_item("main", sold)
 | |
| 	npc_inv:add_item("npc_main", payment)
 | |
| 	-- save the inventory of the npc so that the payment does not get lost
 | |
| 	yl_speak_up.save_npc_inventory( n_id )
 | |
| 	-- store for statistics how many times the player has executed this trade
 | |
| 	-- (this is also necessary to switch to the right target dialog when
 | |
| 	--  dealing with dialog options trades)
 | |
| 	if(not(yl_speak_up.speak_to[pname].trade_done)) then
 | |
| 		yl_speak_up.speak_to[pname].trade_done = 0
 | |
| 	end
 | |
| 	yl_speak_up.speak_to[pname].trade_done = yl_speak_up.speak_to[pname].trade_done + 1
 | |
| 	-- log the trade
 | |
| 	yl_speak_up.log_change(pname, n_id,
 | |
| 		"bought "..tostring(buy_stack:to_string())..
 | |
| 		" for "..tostring(pay_stack:to_string()))
 | |
| 	return {msg = "OK", stock = stock}
 | |
| end
 | |
| 
 | |
| 
 | |
| -- trade for a player (the owner of the NPC): one item(stack) for another
 | |
| --   trade by clicking on the "buy" button instead of moving inventory items around
 | |
| --   checks if payment and buying is possible
 | |
| yl_speak_up.get_fs_trade_via_buy_button = function(player, trade_id)
 | |
| 	local pname = player:get_player_name()
 | |
| 	local n_id = yl_speak_up.speak_to[pname].n_id
 | |
| 	local dialog = yl_speak_up.speak_to[pname].dialog
 | |
| 	-- make sure all necessary table entries exist
 | |
| 	yl_speak_up.setup_trade_limits(dialog)
 | |
| 	local this_trade = dialog.trades[trade_id]
 | |
| 	-- the players' inventory
 | |
| 	local player_inv = player:get_inventory()
 | |
| 	-- the NPCs' inventory
 | |
| 	local npc_inv = minetest.get_inventory({type="detached", name="yl_speak_up_npc_"..tostring(n_id)})
 | |
| 	if(not(this_trade) or not(player_inv) or not(npc_inv)) then
 | |
| 		return yl_speak_up.trade_fail_fs
 | |
| 	end
 | |
| 	-- what the player pays to the npc:
 | |
| 	local pay = dialog.trades[ trade_id ].pay[1]
 | |
| 	-- what the npc sells and the player buys:
 | |
| 	local buy = dialog.trades[ trade_id ].buy[1]
 | |
| 	local pay_name = yl_speak_up.get_trade_item_desc(pay)
 | |
| 	local buy_name = yl_speak_up.get_trade_item_desc(buy)
 | |
| 	local npc_name = minetest.formspec_escape(dialog.n_npc)
 | |
| 	-- get the number of the trade
 | |
| 	local keys = yl_speak_up.speak_to[pname].trade_id_list or {}
 | |
| 	local idx = math.max(1, table.indexof(keys, trade_id))
 | |
| 
 | |
| 	-- the common formspec, shared by actual trade and configuration
 | |
| 	-- no listring here as that would make things more complicated
 | |
| 	local formspec = { -- "size[8.5,8]"..
 | |
| 		yl_speak_up.show_fs_simple_deco(10, 8.8)..
 | |
| 		"container[0.75,0]"..
 | |
| 		"label[4.35,1.4;", npc_name, " sells:]",
 | |
| 		"list[current_player;main;0.2,4.55;8,1;]",
 | |
| 		"list[current_player;main;0.2,5.78;8,3;8]",
 | |
| --		"label[7.0,0.2;Offer ", tostring(idx), "/", tostring(#keys), "]",
 | |
| --		"label[2.5,0.7;Trading with ", npc_name, "]",
 | |
| 		"label[1.5,0.7;Offer no. ",
 | |
| 			tostring(idx), "/", tostring(#keys),
 | |
| 			" from ", npc_name, "]",
 | |
| 		"label[1.5,1.4;You pay:]",
 | |
| 		-- show images of price and what is sold so that the player knows what
 | |
| 		-- it costs and what he will get even if the trade is not possible at
 | |
| 		-- that moment
 | |
| --		"item_image[2.1,2.0;1,1;",
 | |
| 		"item_image_button[2.1,1.9;1.2,1.2;",
 | |
| 			tostring(pay),
 | |
| 			";pay_item_img;",
 | |
| 			"]",
 | |
| --		"item_image[5.1,2.0;1,1;",
 | |
| --			tostring(buy),
 | |
| 		"item_image_button[5.1,1.9;1.2,1.2;",
 | |
| 			tostring(buy),
 | |
| 			";buy_item_img;",
 | |
| 			"]",
 | |
| 		"label[1.5,3.0;",
 | |
| 			pay_name,
 | |
| 			"]",
 | |
| 		"label[4.35,3.0;",
 | |
| 			buy_name,
 | |
| 			"]",
 | |
| 		"image[3.5,2.0;1,1;gui_furnace_arrow_bg.png^[transformR270]",
 | |
| 	}
 | |
| 
 | |
| 	if(not(dialog.trades[ trade_id ].d_id)) then
 | |
| 		-- go back to the trade list
 | |
| 		table.insert(formspec, "button[0.2,0.0;8.0,1.0;back_to_trade_list;Back to trade list]")
 | |
| 		table.insert(formspec, "tooltip[back_to_trade_list;"..
 | |
| 						"Click here once you've traded enough with this "..
 | |
| 						"NPC and want to get back to the trade list.]")
 | |
| 	elseif(true) then
 | |
| 		-- go back to the trade list
 | |
| 		table.insert(formspec, "button[0.2,0.0;3.8,1.0;back_to_trade_list_dialog_trade;Back to trade list]")
 | |
| 		table.insert(formspec, "tooltip[back_to_trade_list_dialog_trade;"..
 | |
| 						"Click here once you've traded enough with this "..
 | |
| 						"NPC and want to get back to the trade list.]")
 | |
| 		-- go back to dialog
 | |
| 		table.insert(formspec, "button[4.2,0.0;3.8,1.0;back_to_dialog;Back to dialog ")
 | |
| 		table.insert(formspec, minetest.formspec_escape(dialog.trades[ trade_id ].d_id)..
 | |
| 					" (option "..
 | |
| 					minetest.formspec_escape(dialog.trades[ trade_id ].o_id)..
 | |
| 					")")
 | |
| 		table.insert(formspec, "]")
 | |
| 		table.insert(formspec, "tooltip[back_to_dialog;"..
 | |
| 						"Click here once you've traded enough with this "..
 | |
| 						"NPC and want to get back to talking with the NPC.]")
 | |
| 	else
 | |
| 		-- go back to dialog
 | |
| 		table.insert(formspec, "button[0.2,0.0;8.0,1.0;back_to_dialog;Back to dialog ")
 | |
| 		table.insert(formspec, minetest.formspec_escape(dialog.trades[ trade_id ].d_id))
 | |
| 		table.insert(formspec, "]")
 | |
| 		table.insert(formspec, "tooltip[back_to_dialog;"..
 | |
| 						"Click here once you've traded enough with this "..
 | |
| 						"NPC and want to get back to talking with the NPC.]")
 | |
| 	end
 | |
| 
 | |
| 	-- show edit button for the owner if in edit_mode
 | |
| 	if(yl_speak_up.may_edit_npc(player, n_id)) then
 | |
| 		-- for trades in trade list: allow delete (new trades can easily be added)
 | |
| 		-- allow delete for trades in trade list even if not in edit mode
 | |
| 		-- (entering edit mode for that would be too much work)
 | |
| 		table.insert(formspec,
 | |
| 			"button[0.2,2.0;1.2,0.9;delete_trade_via_buy_button;Delete]"..
 | |
| 			"tooltip[delete_trade_via_buy_button;"..
 | |
| 				"Delete this trade. You can do so only if\n"..
 | |
| 				"you can edit the NPC as such (i.e. own it).]")
 | |
| 	end
 | |
| 
 | |
| 	-- dry-run: test if the trade can be done
 | |
| 	local res = yl_speak_up.check_trade_via_buy_button(player, trade_id, false)
 | |
| 	if(res.msg == "OK") then
 | |
| 		local buy_str = "Buy"
 | |
| 		local trade_done = yl_speak_up.speak_to[pname].trade_done
 | |
| 		if(trade_done and trade_done > 0) then
 | |
| 			buy_str = "Buy again. Bought: "..tostring(trade_done).."x"
 | |
| 		end
 | |
| 		table.insert(formspec, "button[0.2,3.5;8.0,1.0;buy_directly;")
 | |
| --			"button[6.5,2.0;1.7,0.9;buy_directly;Buy]"..
 | |
| 		table.insert(formspec, buy_str)
 | |
| 		table.insert(formspec, "]")
 | |
| 		table.insert(formspec, "tooltip[buy_directly;Click here in order to buy.]")
 | |
| 	else
 | |
| 		-- set a red background color in order to alert thep layer to the error
 | |
| 		table.insert(formspec, "style_type[button;bgcolor=#FF4444]"..
 | |
| 					"button[0.2,3.5;8.0,1.0;back_from_error_msg;")
 | |
| --		table.insert(formspec, "label[0.5,3.5;")
 | |
| 		table.insert(formspec, res.msg)
 | |
| 		table.insert(formspec, ']')
 | |
| 		-- set the background color for the next buttons back to our normal one
 | |
| 		table.insert(formspec, 'style_type[button;bgcolor=#a37e45]')
 | |
| --		table.insert(formspec, "label[6.5,2.0;Trade not\npossible.]")
 | |
| 	end
 | |
| 	-- how often can this trade be repeated?
 | |
| 	if(res.stock and res.stock > 0) then
 | |
| 		table.insert(formspec, "label[6.5,2.0;Trade ")
 | |
| 		table.insert(formspec, tostring(res.stock))
 | |
| 		table.insert(formspec, " x\navailable]")
 | |
| 	end
 | |
| 
 | |
| 	table.insert(formspec, "container_end[]"..
 | |
| 		"real_coordinates[true]"..
 | |
| 		"button[0.5,1.9;0.8,2.0;prev_trade;<]"..
 | |
| 		"button[11.7,1.9;0.8,2.0;next_trade;>]"..
 | |
| 		"tooltip[prev_trade;Show previous trade offer]"..
 | |
| 		"tooltip[next_trade;Show next trade offer]")
 | |
| 	return table.concat(formspec, '')
 | |
| end
 |