Skip to content

LuaUI Server

wezzzyrek1 edited this page Sep 24, 2026 · 4 revisions

Lua UI - Client and Server Together

The example below is the Coin Exchange window - WCoin converted to Ruud at a fixed rate - built on both sides. Other pages develop the same system: its HUD ticker, its history list and its voucher field.

A HUD panel that only displays local information needs no server half at all.

The open gate

When your script shows a floating window, the client asks the server for permission. The server's handler returns 0 to allow it and anything else to refuse; on a refusal the window simply never appears.

That gate is where server-side conditions belong - level requirements, a map restriction, an event window, a plugin switched off. Checking them on the client only hides the button; checking them here actually enforces it.

If no handler is registered for a window id, the open is allowed. That is deliberate, so a purely decorative window needs no server code.

One window at a time

The gate is also where you stop a second window from landing on top of one the player already has open. oPlayer:GetIfStateType() reads their interface state - Enums.IfState.NONE when nothing is open, or a value like TRADE, SHOP, WAREHOUSE or an NPC dialog when a native interface holds them.

UIWindow.OnOpen(S.id, function(oPlayer)        -- S = your window's protocol table
	if oPlayer:GetIfStateType() ~= Enums.IfState.NONE then
		return 1   -- refuse: a trade, shop, warehouse, NPC... is already open
	end

	oPlayer:SetIfState(1, 0, Enums.IfState.LUAWINDOW)   -- mark the player busy
	return 0
end)

Reading the state refuses the open while another interface is up. Setting it to LUAWINDOW does the reverse - the native systems now treat the player as busy and will not start a trade or a shop over your window, and a second Lua window with the same guard is refused.

The engine clears it on close. Hiding a window the server let open - ESC, or the script hiding it (visible = false, its X button) - sends the game's ordinary close-window packet on the next frame. The server calls onCloseWindow, and when that returns 0, as shipped, it ends whatever interface is open and resets the state to NONE. A hud window never asked to open, so it never reports a close either.

The packet names no window - the game's own NPC windows send the same one - so onCloseWindow cannot tell which window closed. A second window without the guard clears the first one's state when it closes. A window removed while open (UI.DelWindow or a script reload), or hidden before the server's answer arrives, sends nothing, which leaves the player busy until the next close or a relog.

A window that needs its own cleanup sends a close action from the client. ESC hides a window without calling its script, so send it from an Event.on("update") handler watching win:isVisible(), not only from the X button:

UIWindow.On(S.id, S.CLOSE, function(oPlayer)   -- S.CLOSE: a use action you define
	-- whatever the window keeps for the player
end)

Leaving IfState alone is not neutral: the close packet ends whatever interface the player has open, so closing any window but a hud cancels a trade and ends a shop or warehouse. A panel that must sit beside those should be a hud; any other window is safer with the full guard. If you want the check but not the block, keep the guard and drop the SetIfState call - reading GetIfStateType on open costs nothing and can never leave a player stuck busy, but a trade or shop started after the open still ends when the window closes. The full type list is in Player structure.

The protocol entry

Both sides need the same numbers. Client, in UI\Protocol.lua:

P.Exchange = {
	id = R.win.exchange,

	RATE    = 1,   -- S->C  rate and balances
	CONVERT = 2,   -- C->S  dword amount
	HISTORY = 3,   -- S->C  past conversions

	MIN = 100,
	MAX = 100000,

	TICKER_OP = 110,   -- general channel: rate + today's allowance

	RESULT = {
		OK         = 0,
		BAD_AMOUNT = 10,
		NO_FUNDS   = 11,
		LIMIT      = 12,
	},
}

Server, in Defines\UIProtocol.lua, the same values:

UIProtocol.WINDOW = {
	DEMO     = 1,
	EXCHANGE = 3,
}

UIProtocol.Exchange = {
	id = UIProtocol.WINDOW.EXCHANGE,

	RATE    = 1,
	CONVERT = 2,
	HISTORY = 3,

	MIN = 100,
	MAX = 100000,

	TICKER_OP = 110,   -- general channel: rate + today's allowance

	RESULT = {
		OK         = 0,
		BAD_AMOUNT = 10,
		NO_FUNDS   = 11,
		LIMIT      = 12,
	},
}

Change one, change the other, in the same sitting.

The server half

Data\Plugins\LuaAPI\Windows\CoinExchange.lua. The rate is fixed in the script; the daily allowance and the history are per character and kept in the database.

local S = UIProtocol.Exchange

-- query ids are named once in Defines\Enums.lua (Enums.QueryDS)
local Q_LOAD_DAILY = Enums.QueryDS.EXCHANGE_LOAD_DAILY
local Q_LOAD_HISTORY = Enums.QueryDS.EXCHANGE_LOAD_HISTORY
local Q_SAVE = Enums.QueryDS.EXCHANGE_SAVE

-- The system's own state, referred to by this name from the other scripts.
Exchange = {
	rate = 10, -- 10 WCoin -> 1 Ruud
	DAILY_LIMIT = 100000, -- WCoin one character may convert per day
	used = {}, -- [CharacterId] = { day, wcoin }, filled by the load
	history = {}, -- [CharacterId] = { { date, spent, gained }, ... }
}

local function CharId(oPlayer)
	return oPlayer.userData and oPlayer.userData.CharacterId or 0
end

-- Today's total, from the cache the load fills. The cache carries its day, so it
-- reads as 0 after midnight without a separate rollover.
function Exchange.UsedToday(oPlayer)
	local rec = Exchange.used[CharId(oPlayer)]
	if rec == nil or rec.day ~= os.date("%Y-%m-%d") then return 0 end
	return rec.wcoin
end

local function sendRates(oPlayer)
	local w = UIPacket.Writer()
	w:dword(Exchange.rate)
	w:dword(Coin.Get(oPlayer, Enums.CoinType.WCOIN))
	w:dword(Coin.Get(oPlayer, Enums.CoinType.RUUD))

	UIWindow.Send(oPlayer, S.id, S.RATE, S.RESULT.OK, w)
end

sendHistory and Exchange.SendTicker, called below, are built on the scrolling and protocol pages; they send the history list and the HUD figure.

Loading on open

DB.QueryDS is asynchronous - it returns nothing, and the result arrives later in onDSDBQueryReceive. So the window opens with whatever is cached and refreshes when the DataServer answers.

CoinExchangeDB = {} -- global, so the callback in CallbacksDB.lua can reach it

local pendDaily = {} -- [playerIndex] = { charId, value }
local pendHist = {} -- [playerIndex] = { charId, rows }

local function RequestLoad(oPlayer)
	local charId = CharId(oPlayer)
	if charId <= 0 then return end

	pendDaily[oPlayer.Index] = { charId = charId, value = 0 }
	pendHist[oPlayer.Index] = { charId = charId, rows = {} }

	DB.QueryDS(oPlayer.Index, Q_LOAD_DAILY,
		string.format("EXEC IGC_CoinExchange_LoadDaily %d", charId))
	DB.QueryDS(oPlayer.Index, Q_LOAD_HISTORY,
		string.format("EXEC IGC_CoinExchange_LoadHistory %d, 20", charId))
end

UIWindow.OnOpen(S.id, function(oPlayer)
	RequestLoad(oPlayer)
	sendRates(oPlayer)
	sendHistory(oPlayer)
	return 0
end)

Results arrive one column per row. A second packet is a separate async task and can overtake the first, so each value goes into its own (packet, row) bucket and the order stops mattering.

function CoinExchangeDB.OnRow(iPlayerIndex, iQuery, iPacket, iRow, oRow)
	if iQuery == Q_LOAD_DAILY then
		local p = pendDaily[iPlayerIndex]
		if p and oRow:GetColumnName() == "UsedWCoin" then
			p.value = tonumber(oRow:GetValue()) or 0
		end
	elseif iQuery == Q_LOAD_HISTORY then
		local p = pendHist[iPlayerIndex]
		if p then
			local key = iPacket * 1000 + iRow
			p.rows[key] = p.rows[key] or {}
			p.rows[key][oRow:GetColumnName()] = oRow:GetValue()
		end
	end
end

function CoinExchangeDB.OnComplete(iPlayerIndex, iQuery)
	local oPlayer = Player.GetObjByIndex(iPlayerIndex)

	if iQuery == Q_LOAD_DAILY then
		local p = pendDaily[iPlayerIndex]
		pendDaily[iPlayerIndex] = nil
		if p == nil then return end

		Exchange.used[p.charId] = { day = os.date("%Y-%m-%d"), wcoin = p.value }
		if oPlayer and CharId(oPlayer) == p.charId then Exchange.SendTicker(oPlayer) end

	elseif iQuery == Q_LOAD_HISTORY then
		local p = pendHist[iPlayerIndex]
		pendHist[iPlayerIndex] = nil
		if p == nil then return end

		local keys = {}
		for k in pairs(p.rows) do keys[#keys + 1] = k end
		table.sort(keys) -- SQL returns newest first; the key preserves that order

		local list = {}
		for _, k in ipairs(keys) do
			local row = p.rows[k]
			list[#list + 1] = {
				date = row.ConvertLabel or "",
				spent = tonumber(row.SpentWCoin) or 0,
				gained = tonumber(row.GainedRuud) or 0,
			}
		end
		Exchange.history[p.charId] = list
		if oPlayer and CharId(oPlayer) == p.charId then sendHistory(oPlayer) end
	end
end

Route those results in CallbacksDB.lua, by query id:

function onDSDBQueryReceive(iPlayerIndex, iQueryNumber, bIsLastPacket, iCurrentRow, btColumnCount, btCurrentPacket, oRow)
	local Q = Enums.QueryDS

	if oRow ~= nil and (iQueryNumber == Q.EXCHANGE_LOAD_DAILY or iQueryNumber == Q.EXCHANGE_LOAD_HISTORY) then
		CoinExchangeDB.OnRow(iPlayerIndex, iQueryNumber, btCurrentPacket, iCurrentRow, oRow)
	end

	if (bIsLastPacket == 1 or bIsLastPacket == true) and
	   (iQueryNumber == Q.EXCHANGE_LOAD_DAILY or iQueryNumber == Q.EXCHANGE_LOAD_HISTORY) then
		CoinExchangeDB.OnComplete(iPlayerIndex, iQueryNumber)
	end
end

A character who has converted nothing today still gets an answer: an empty result arrives as one callback with oRow == nil and the last-packet flag set, so OnComplete runs and the cache settles at 0.

Convert, and persist

UIWindow.On(S.id, S.CONVERT, function(oPlayer, reader)
	local amount = reader:dword()

	if amount < S.MIN or amount > S.MAX or amount % Exchange.rate ~= 0 then
		UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.BAD_AMOUNT)
		return
	end

	if Exchange.UsedToday(oPlayer) + amount > Exchange.DAILY_LIMIT then
		UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.LIMIT)
		return
	end

	if Coin.Charge(oPlayer, Enums.CoinType.WCOIN, amount) == false then
		UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.NO_FUNDS)
		return
	end

	local gained = amount // Exchange.rate
	Coin.Add(oPlayer, Enums.CoinType.RUUD, gained)

	-- bump the cached daily counter, keyed to today so midnight resets it
	local charId = CharId(oPlayer)
	local today = os.date("%Y-%m-%d")
	local rec = Exchange.used[charId]
	if rec == nil or rec.day ~= today then
		rec = { day = today, wcoin = 0 }
		Exchange.used[charId] = rec
	end
	rec.wcoin = rec.wcoin + amount

	-- mirror the new row into the cache so the window updates without a reload;
	-- the same row is what the procedure writes to the database
	local list = Exchange.history[charId] or {}
	Exchange.history[charId] = list
	table.insert(list, 1, { date = os.date("%m-%d %H:%M"), spent = amount, gained = gained })

	-- persist: one procedure bumps the counter and appends the history row
	DB.QueryDS(oPlayer.Index, Q_SAVE,
		string.format("EXEC IGC_CoinExchange_AddConversion %d, %d, %d", charId, amount, gained))

	UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.OK)
	sendRates(oPlayer)
	sendHistory(oPlayer)
	Exchange.SendTicker(oPlayer)
end)

Do not re-read the database right after the write to refresh the window: the write is asynchronous too, and a load fired straight after it can overtake it and read the old rows. Updating the cache in place, as above, avoids that race.

Register the file in Main.lua:

LoadScript(BASE .. "Windows\\CoinExchange.lua")

The IGC_CoinExchange_* procedures and their tables are created by SQL Scripts\CoinExchange.sql, shipped with the server files - run it once against the game database. This page does not reproduce the SQL.

Rules the handler follows:

  • Charge before you grant. Coin.Charge deducts only when the balance covers it and reports false otherwise, so the failure path costs the player nothing.
  • Validate the amount even though the field is numeric. The field limits what a player can type; it does not limit what can arrive.
  • Only ints reach the query. charId, amount and gained are numbers, built with %d; never interpolate player text into SQL.
  • Re-send the state after a change. The client should never have to guess the new balance from what it asked for.

// is integer division and exists in the server's Lua 5.3. Plain / yields a float, which is not what a function expecting a whole amount should be handed.

Anything that creates or deletes items has one more obligation: Player.CheckItemAction(oPlayer) must return Enums.ItemActionBlock.OK first. Currency is not affected by it.

The client half

Data\Custom\Scripts\UI\Windows\Demo\CoinExchange.lua:

local ui  = require("UI")
local R   = require("Registry")
local net = require("Net")
local P   = require("Protocol")

local S = P.Exchange

-- This side's half of the system state, global so the HUD ticker script can read
-- it too. Written `X or {...}` because either script may load first; the server
-- keeps its own table under the same name.
Exchange = Exchange or { rate = 0, usedToday = 0, dailyLimit = 0, history = {} }

local win = ui.Window{
	id = R.win.exchange,
	center = true,
	w = 280, height = 380,
	header = 34,
	title = "Coin Exchange",
	drag = { 0, 0, 280, 34 },
}

if win then
	local col = win:Column{ top = 42, padding = 16, spacing = 6, align = "center" }

	local lRate    = col:Label("rate: -",    { slide = false })
	local lBalance = col:Label("balance: -", { slide = false })

	local input = col:TextInput{
		w = 180, h = 20,
		numeric = true, maxValue = S.MAX,
		placeholder = "WCoin to convert",
	}

	local status = col:Label("", { color = RGBA(255, 120, 120, 255), slide = false })

	col:Button("Convert", {
		w = 120, h = 26,
		onClick = function()
			local amount = tonumber(input.text) or 0
			status.text = ""
			net.send(S.id, S.CONVERT):dword(amount):send()
		end,
	})

	net.on(S.id, S.RATE, function(result, reader)
		Exchange.rate = reader:dword()

		local wcoin, ruud = reader:dword(), reader:dword()
		lRate.text    = ("%d WCoin = 1 Ruud"):format(Exchange.rate)
		lBalance.text = ("you have %d WCoin, %d Ruud"):format(wcoin, ruud)
	end)

	net.on(S.id, S.CONVERT, function(result)
		if result == S.RESULT.OK then
			input.text = ""
			status.text = ""
		elseif result == S.RESULT.NO_FUNDS then
			status.text = "Not enough WCoin."
		elseif result == S.RESULT.LIMIT then
			status.text = "Daily limit reached."
		else
			status.text = "Enter a multiple of the rate."
		end
	end)

	Event.on("update", function()
		if Input.KeyPressed(VK.F10) then
			win:show(not win:isVisible())
		end
	end)
end

The window is deliberately taller than its controls need: the scrolling page fills the space below them with the conversion history, and the design page dresses the same 280x380 frame in the game's artwork.

How a message finds its handler

Outgoing, from the client:

net.send(windowId, action):dword(v):text(s):send()

Incoming, on the client:

net.on(windowId, action, function(result, reader, iParam, windowId) end)
net.on(windowId, function(result, reader, iParam) end)   -- fallback for the window

On the server:

UIWindow.On(id, action, function(oPlayer, reader, iParam) end)
UIWindow.On(id, function(oPlayer, reader, iParam) end)    -- fallback
UIWindow.Send(oPlayer, id, action, result, writer)        -- writer optional

The routing key is always the pair (window id, action). A message with no handler is dropped quietly, so an older client ignores an action it does not know rather than erroring.

The server's own entry points live in CallbacksUI.lua and forward to UIWindow.DispatchOpen, UIWindow.DispatchUse and Net.Dispatch. Those three are plumbing between the entry points and the routing tables - nothing in a window script should call them.

When a handler fails

An error inside a handler is caught and written to the server log; it does not disconnect the player or stop other windows. The window will simply never answer, which from the player's side looks like a button that does nothing - so check the log first when a window goes quiet.

Opening a window from an NPC

Talking to an NPC can open a window, or one of the client's own panels, instead of the NPC's dialog. The server holds the binding, in Includes\NpcWindow.lua:

NpcWindow.Bind({ class = 2497, window = UIProtocol.WINDOW.DEMO, only = true })
NpcWindow.Bind({ class = 2495, map = 0, panel = UIProtocol.PANEL.RANKING })
Field Meaning
class the NPC's class
map optional - binds only the NPC on that map
window a window id from UIProtocol.WINDOW
panel instead of window, one of the client's panels from UIProtocol.PANEL - the values of the client's UI.PANEL
only true - the window opens, and takes actions, only for a player who talked to that NPC and stands within 5 tiles of it. It holds for the window wherever it opens from, keys and buttons included

Put the line in the window's own server script. The NPC is an ordinary MonsterSpawn.xml entry whose class is marked IsNpc="1" - see A talking NPC.

The talk reaches onNpcTalk, which returns 1 for a bound NPC, so its own dialog stays shut. The server sends general-channel opcode 120 naming the window, and the client's Windows\NpcWindow.lua shows it the ordinary way: it asks the server, and the open gate still decides. A panel is the client's own and opens at once. A hud window is never opened this way, since it shows without asking.

only is checked on every open and every action, so walking away with the window up and pressing a button does nothing. Without only the NPC is one more way to open a window that also opens from a key or a button.

Do not bind an NPC that should open one of the game's own windows - a shop, a warehouse or a chaos machine. Those come from NpcType and ShopList.xml, and a binding takes the NPC over.

See Also

Clone this wiki locally