You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
509 lines
14 KiB
509 lines
14 KiB
|
1 day ago
|
# Module Authoring Guide
|
||
|
|
|
||
|
|
Guide for developing modules for the ARZ Web Helper framework.
|
||
|
|
|
||
|
|
## Module Structure
|
||
|
|
|
||
|
|
A module is a directory containing at minimum `init.lua`:
|
||
|
|
|
||
|
|
```
|
||
|
|
modules/mymodule/
|
||
|
|
├── init.lua -- Required: module entry point
|
||
|
|
└── static/ -- Optional: custom web UI
|
||
|
|
├── index.html
|
||
|
|
├── style.css
|
||
|
|
└── app.js
|
||
|
|
```
|
||
|
|
|
||
|
|
Modules placed in `http_framework/modules/` are bundled with the framework. Modules in `more_modules/` are separate (gitignored). Both are loaded identically at runtime.
|
||
|
|
|
||
|
|
## Module Interface
|
||
|
|
|
||
|
|
```lua
|
||
|
|
local M = {}
|
||
|
|
|
||
|
|
function M.init(fw)
|
||
|
|
-- Required. Called once when module is loaded.
|
||
|
|
-- fw: framework API object (namespaced per module)
|
||
|
|
end
|
||
|
|
|
||
|
|
function M.render(ui, state)
|
||
|
|
-- Optional. Generates UI for both web and in-game imgui.
|
||
|
|
-- ui: abstract widget builder
|
||
|
|
-- state: persistent table (survives re-renders, cleared on module reset)
|
||
|
|
end
|
||
|
|
|
||
|
|
function M.unload()
|
||
|
|
-- Optional. Cleanup when module is unloaded/reloaded.
|
||
|
|
-- Cancel timers, reset globals, release resources.
|
||
|
|
end
|
||
|
|
|
||
|
|
return M
|
||
|
|
```
|
||
|
|
|
||
|
|
## Framework API (fw object)
|
||
|
|
|
||
|
|
### Logging
|
||
|
|
|
||
|
|
```lua
|
||
|
|
fw.log(level, tag, message, ...)
|
||
|
|
-- level: "INFO", "WARN", "ERROR", "DEBUG"
|
||
|
|
-- tag: short identifier (e.g. module name)
|
||
|
|
-- Additional args are concatenated with spaces
|
||
|
|
-- Also broadcasts to WebSocket clients as __log event
|
||
|
|
```
|
||
|
|
|
||
|
|
### JSON
|
||
|
|
|
||
|
|
```lua
|
||
|
|
local tbl = fw.json_decode(json_string)
|
||
|
|
local str = fw.json_encode(table)
|
||
|
|
-- Uses cjson if available, falls back to naive parser
|
||
|
|
-- Fallback only handles flat {"key":"value"} — always ensure cjson is present
|
||
|
|
```
|
||
|
|
|
||
|
|
### Encoding (Win-1251 / UTF-8)
|
||
|
|
|
||
|
|
```lua
|
||
|
|
local utf8 = fw.to_utf8(win1251_string)
|
||
|
|
local win = fw.to_win1251(utf8_string)
|
||
|
|
-- Game uses CP1251 for Cyrillic text
|
||
|
|
-- Web and DB use UTF-8
|
||
|
|
-- Convert before sending to game APIs (sampProcessChatInput, etc.)
|
||
|
|
```
|
||
|
|
|
||
|
|
### API Handlers
|
||
|
|
|
||
|
|
Register HTTP endpoints accessible at `POST /api/{module}/{action}`:
|
||
|
|
|
||
|
|
```lua
|
||
|
|
fw.on_api("mymod", "do_something", function(body)
|
||
|
|
-- body: raw request body string (JSON)
|
||
|
|
local data = fw.json_decode(body)
|
||
|
|
-- ... process ...
|
||
|
|
return fw.json_encode({ok = true, result = "done"})
|
||
|
|
end)
|
||
|
|
```
|
||
|
|
|
||
|
|
The handler receives the POST body as a string and must return a JSON string. Errors in handlers are caught by the framework and returned as `{"error":"..."}`.
|
||
|
|
|
||
|
|
Timeout: 2 seconds. If the game thread is blocked, the request returns `{"error":"lua timeout"}`.
|
||
|
|
|
||
|
|
### Event Interception
|
||
|
|
|
||
|
|
Hook into SA-MP network events:
|
||
|
|
|
||
|
|
```lua
|
||
|
|
fw.on_event(event_name, handler, priority)
|
||
|
|
-- event_name: SAMP event (e.g. "onServerMessage", "onShowDialog", "onSendChat")
|
||
|
|
-- handler: function receiving event arguments
|
||
|
|
-- priority: lower = fires first (default 50)
|
||
|
|
-- Return false to block the event from propagating
|
||
|
|
-- Return nil/true to allow propagation
|
||
|
|
```
|
||
|
|
|
||
|
|
Example — block outgoing messages containing "spam":
|
||
|
|
```lua
|
||
|
|
fw.on_event("onSendChat", function(message)
|
||
|
|
if message:lower():find("spam") then
|
||
|
|
fw.log("WARN", "MYMOD", "Blocked: " .. message)
|
||
|
|
return false -- cancel the event
|
||
|
|
end
|
||
|
|
end, 10) -- priority 10: runs before default (50)
|
||
|
|
```
|
||
|
|
|
||
|
|
Common events:
|
||
|
|
- `onServerMessage(color, text)` — incoming chat message
|
||
|
|
- `onSendChat(message)` — outgoing chat message
|
||
|
|
- `onShowDialog(id, style, title, button1, button2, text)` — server dialog
|
||
|
|
- All SAMP RPCs and packets from `samp.events.INTERFACE`
|
||
|
|
|
||
|
|
Events are also broadcast to WebSocket clients as JSON:
|
||
|
|
```json
|
||
|
|
{"type":"event", "event":"onServerMessage", "args":[color, text]}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Commands
|
||
|
|
|
||
|
|
```lua
|
||
|
|
fw.command("mycommand", function(args)
|
||
|
|
-- args: string after the command (e.g. "/mycommand hello world" → "hello world")
|
||
|
|
fw.add_chat_message("You said: " .. args, -1)
|
||
|
|
end)
|
||
|
|
-- Registers /mycommand with SA-MP and the framework's command list
|
||
|
|
-- Visible in admin panel Commands tab
|
||
|
|
```
|
||
|
|
|
||
|
|
### Chat / Game Interaction
|
||
|
|
|
||
|
|
```lua
|
||
|
|
fw.chat(text)
|
||
|
|
-- Process text as chat input (sends message or executes /command)
|
||
|
|
|
||
|
|
fw.add_chat_message(text, color)
|
||
|
|
-- Show a local-only chat message (not sent to server)
|
||
|
|
-- color: ARGB integer or -1 for default white
|
||
|
|
|
||
|
|
fw.respond_dialog(id, button, listbox_id, input)
|
||
|
|
-- Send a dialog response to the server
|
||
|
|
-- id: dialog ID, button: 0=close/1=confirm, listbox_id: selected row, input: text
|
||
|
|
|
||
|
|
fw.unlock_dialog()
|
||
|
|
-- Sends /mm then auto-closes the menu dialog (id=722)
|
||
|
|
-- Used to unblock the dialog system after automated dialog interaction
|
||
|
|
```
|
||
|
|
|
||
|
|
### Timers
|
||
|
|
|
||
|
|
```lua
|
||
|
|
local cancel = fw.timer_once(milliseconds, callback)
|
||
|
|
-- Schedules a one-shot callback after delay
|
||
|
|
-- Returns a cancel function: call cancel() to abort
|
||
|
|
-- Runs in a separate lua_thread coroutine
|
||
|
|
-- Use for deferred dialog responses, delayed actions
|
||
|
|
```
|
||
|
|
|
||
|
|
Pattern — deferred dialog response (from btc module):
|
||
|
|
```lua
|
||
|
|
fw.on_event("onShowDialog", function(id, style, title, b1, b2, text)
|
||
|
|
if id == 7238 then
|
||
|
|
fw.timer_once(5, function()
|
||
|
|
fw.respond_dialog(id, 1, 0, "")
|
||
|
|
end)
|
||
|
|
return false -- hide dialog from player
|
||
|
|
end
|
||
|
|
end, 20)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Database (Key-Value, Auto-Namespaced)
|
||
|
|
|
||
|
|
All keys are automatically prefixed with `"{module}."` for isolation between modules.
|
||
|
|
|
||
|
|
#### Simple Operations
|
||
|
|
|
||
|
|
```lua
|
||
|
|
local value = fw.db_get(key) -- Returns string or nil
|
||
|
|
local ok = fw.db_set(key, value) -- Returns true/false
|
||
|
|
local ok = fw.db_delete(key) -- Returns true/false
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Batch Operations (Recommended for Multiple Keys)
|
||
|
|
|
||
|
|
```lua
|
||
|
|
-- Get multiple keys at once
|
||
|
|
local results = fw.db_get_many({"key1", "key2", "key3"})
|
||
|
|
-- Returns: {key1 = "val1", key2 = "val2"} (missing keys omitted)
|
||
|
|
|
||
|
|
-- Set multiple keys at once
|
||
|
|
fw.db_set_many({key1 = "val1", key2 = "val2", key3 = "val3"})
|
||
|
|
|
||
|
|
-- Get all keys with a prefix
|
||
|
|
local items = fw.db_get_prefix("settings.")
|
||
|
|
-- Returns: {["settings.delay"] = "5", ["settings.mode"] = "auto"}
|
||
|
|
```
|
||
|
|
|
||
|
|
These batch methods use `fw.db_batch()` internally, which sends operations to the async SQLite backend. They block until results are available (safe in coroutines with `wait()`).
|
||
|
|
|
||
|
|
#### Persistence Patterns
|
||
|
|
|
||
|
|
Settings pattern (from btc module):
|
||
|
|
```lua
|
||
|
|
local settings = {delay = 5, mode = "auto"}
|
||
|
|
|
||
|
|
local function save_settings()
|
||
|
|
local kv = {}
|
||
|
|
for k, v in pairs(settings) do kv["s." .. k] = tostring(v) end
|
||
|
|
fw.db_set_many(kv)
|
||
|
|
end
|
||
|
|
|
||
|
|
local function load_settings()
|
||
|
|
local all = fw.db_get_prefix("s.")
|
||
|
|
for k, default in pairs(settings) do
|
||
|
|
local val = all["s." .. k]
|
||
|
|
if val then
|
||
|
|
if type(default) == "number" then settings[k] = tonumber(val) or default
|
||
|
|
else settings[k] = val end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
### Module Registration
|
||
|
|
|
||
|
|
```lua
|
||
|
|
fw.register_module(name, static_dir)
|
||
|
|
-- Register a module's static file directory for web serving at /m/{name}/
|
||
|
|
-- static_dir: absolute path to directory containing index.html etc.
|
||
|
|
-- Use fw.modules_dir .. "/mymod/static" for the standard path
|
||
|
|
```
|
||
|
|
|
||
|
|
### Other
|
||
|
|
|
||
|
|
```lua
|
||
|
|
fw.modules_dir -- Path to the modules directory (string)
|
||
|
|
fw.rust -- Direct FFI reference to libarz_core.so (advanced use only)
|
||
|
|
fw.push_event(name, json_args) -- Broadcast custom event to WebSocket clients
|
||
|
|
fw.fire_event(event_name, ...) -- Manually trigger event interceptors
|
||
|
|
```
|
||
|
|
|
||
|
|
## UI Rendering (render function)
|
||
|
|
|
||
|
|
Modules can provide a `render(ui, state)` function for automatic UI generation. The same code renders both in the web browser (via JSON widgets) and in-game (via mimgui/ImGui).
|
||
|
|
|
||
|
|
### How It Works
|
||
|
|
|
||
|
|
1. Module defines `M.render(ui, state)`
|
||
|
|
2. Framework detects `render()` and registers the module as `__render__`
|
||
|
|
3. Web requests to `/m/{module}/` serve a generic UI page (`ui_page.html`)
|
||
|
|
4. The page periodically calls `POST /api/{module}/__render` with user interactions
|
||
|
|
5. Framework calls `M.render()` with a UI builder that records widgets as JSON
|
||
|
|
6. Browser renders the JSON widget tree as HTML
|
||
|
|
|
||
|
|
For imgui: the framework calls `M.render()` with an imgui wrapper that translates widget calls to real ImGui commands.
|
||
|
|
|
||
|
|
### Widget API
|
||
|
|
|
||
|
|
#### Text & Layout
|
||
|
|
|
||
|
|
```lua
|
||
|
|
ui.text("Plain text")
|
||
|
|
ui.text_colored(r, g, b, a, "Colored text") -- RGBA 0.0-1.0
|
||
|
|
ui.separator() -- Horizontal line
|
||
|
|
ui.spacing() -- Vertical gap
|
||
|
|
ui.sameline() -- Next widget on same line
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Interactive Widgets
|
||
|
|
|
||
|
|
```lua
|
||
|
|
-- Button: returns true on the frame it was clicked
|
||
|
|
if ui.button("Click me") then
|
||
|
|
-- handle click
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Text input: returns current value
|
||
|
|
local name = ui.input("Label", current_value)
|
||
|
|
|
||
|
|
-- Integer input
|
||
|
|
local count = ui.input_int("Count", current_value)
|
||
|
|
|
||
|
|
-- Checkbox: returns boolean
|
||
|
|
local enabled = ui.checkbox("Enable feature", current_bool)
|
||
|
|
|
||
|
|
-- Integer slider
|
||
|
|
local level = ui.slider_int("Level", current_value, min, max)
|
||
|
|
|
||
|
|
-- Float slider
|
||
|
|
local alpha = ui.slider_float("Alpha", current_value, 0, 1)
|
||
|
|
|
||
|
|
-- Dropdown
|
||
|
|
local idx = ui.combo("Choose", selected_index, {"Option A", "Option B", "Option C"})
|
||
|
|
-- selected_index is 0-based
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Progress
|
||
|
|
|
||
|
|
```lua
|
||
|
|
ui.progress(0.75, "75%") -- fraction 0.0-1.0
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Containers
|
||
|
|
|
||
|
|
```lua
|
||
|
|
-- Collapsible section
|
||
|
|
if ui.collapsing("Details") then
|
||
|
|
ui.text("Hidden content")
|
||
|
|
ui.collapsing_end()
|
||
|
|
end
|
||
|
|
|
||
|
|
-- Tab bar with tabs
|
||
|
|
if ui.tab_bar("MyTabs") then
|
||
|
|
if ui.tab_item("Tab 1") then
|
||
|
|
ui.text("Tab 1 content")
|
||
|
|
ui.tab_end()
|
||
|
|
end
|
||
|
|
if ui.tab_item("Tab 2") then
|
||
|
|
ui.text("Tab 2 content")
|
||
|
|
ui.tab_end()
|
||
|
|
end
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
### State Persistence
|
||
|
|
|
||
|
|
The `state` table passed to `render()` persists between renders (it's stored in `module_states[name]`). Use it for UI state that doesn't need database persistence:
|
||
|
|
|
||
|
|
```lua
|
||
|
|
function M.render(ui, state)
|
||
|
|
state.counter = state.counter or 0
|
||
|
|
ui.text("Count: " .. state.counter)
|
||
|
|
if ui.button("Increment") then
|
||
|
|
state.counter = state.counter + 1
|
||
|
|
end
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
State is cleared on module reset (admin panel "Reset" button).
|
||
|
|
|
||
|
|
### Interactions Model
|
||
|
|
|
||
|
|
The web UI sends interactions as a JSON object mapping widget IDs to values:
|
||
|
|
- Buttons: `{id: "click"}`
|
||
|
|
- Inputs: `{id: "new text value"}`
|
||
|
|
- Checkboxes: `{id: "true"/"false"}`
|
||
|
|
- Sliders/combos: `{id: "number_as_string"}`
|
||
|
|
|
||
|
|
Widget IDs are auto-generated (`b1`, `i2`, `c3`, etc.) in order of creation. This means **widget order must be stable** — conditional widgets that change position will break ID mapping.
|
||
|
|
|
||
|
|
## Module Patterns
|
||
|
|
|
||
|
|
### Pattern 1: Minimal Module (API only)
|
||
|
|
|
||
|
|
```lua
|
||
|
|
local M = {}
|
||
|
|
|
||
|
|
function M.init(fw)
|
||
|
|
fw.on_api("ping", "check", function()
|
||
|
|
return '{"ok":true,"time":' .. os.time() .. '}'
|
||
|
|
end)
|
||
|
|
fw.command("ping", function()
|
||
|
|
fw.add_chat_message("Pong!", -1)
|
||
|
|
end)
|
||
|
|
fw.log("INFO", "PING", "Module loaded")
|
||
|
|
end
|
||
|
|
|
||
|
|
return M
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pattern 2: Auto-UI Module (render)
|
||
|
|
|
||
|
|
No static files needed. Framework generates the web page automatically.
|
||
|
|
|
||
|
|
```lua
|
||
|
|
local M = {}
|
||
|
|
local fw
|
||
|
|
|
||
|
|
function M.init(f)
|
||
|
|
fw = f
|
||
|
|
fw.log("INFO", "MYMOD", "Loaded")
|
||
|
|
end
|
||
|
|
|
||
|
|
function M.render(ui, state)
|
||
|
|
state.name = state.name or ""
|
||
|
|
state.name = ui.input("Your name", state.name)
|
||
|
|
|
||
|
|
if ui.button("Greet") then
|
||
|
|
fw.add_chat_message("Hello, " .. state.name .. "!", -1)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
return M
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pattern 3: Custom HTML Module
|
||
|
|
|
||
|
|
For rich UIs beyond what the widget builder offers.
|
||
|
|
|
||
|
|
```lua
|
||
|
|
local M = {}
|
||
|
|
|
||
|
|
function M.init(fw)
|
||
|
|
local static_dir = fw.modules_dir .. "/mymod/static"
|
||
|
|
fw.register_module("mymod", static_dir)
|
||
|
|
|
||
|
|
fw.on_api("mymod", "getData", function()
|
||
|
|
return fw.json_encode({items = {"a", "b", "c"}})
|
||
|
|
end)
|
||
|
|
end
|
||
|
|
|
||
|
|
return M
|
||
|
|
```
|
||
|
|
|
||
|
|
```html
|
||
|
|
<!-- modules/mymod/static/index.html -->
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html>
|
||
|
|
<head><title>My Module</title></head>
|
||
|
|
<body>
|
||
|
|
<div id="app"></div>
|
||
|
|
<script>
|
||
|
|
// Connect to WebSocket for real-time events
|
||
|
|
const ws = new WebSocket('ws://' + location.host + '/ws');
|
||
|
|
ws.onmessage = (e) => {
|
||
|
|
const msg = JSON.parse(e.data);
|
||
|
|
if (msg.event === 'onServerMessage') {
|
||
|
|
// Handle chat message
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Call module API
|
||
|
|
async function loadData() {
|
||
|
|
const res = await fetch('/api/mymod/getData', {method: 'POST'});
|
||
|
|
const data = await res.json();
|
||
|
|
document.getElementById('app').textContent = JSON.stringify(data);
|
||
|
|
}
|
||
|
|
loadData();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pattern 4: Event Interceptor with Dialog Automation
|
||
|
|
|
||
|
|
From the btc module — intercept server dialogs, parse content, respond automatically:
|
||
|
|
|
||
|
|
```lua
|
||
|
|
function M.init(fw)
|
||
|
|
fw.on_event("onShowDialog", function(id, style, title, b1, b2, text)
|
||
|
|
if id == 12345 then -- your target dialog ID
|
||
|
|
-- Parse dialog text
|
||
|
|
for line in text:gmatch("[^\n]+") do
|
||
|
|
local value = line:match("Balance: (%d+)")
|
||
|
|
if value then
|
||
|
|
fw.log("INFO", "MYMOD", "Balance: " .. value)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
-- Auto-respond after small delay
|
||
|
|
fw.timer_once(5, function()
|
||
|
|
fw.respond_dialog(id, 1, 0, "") -- click OK
|
||
|
|
end)
|
||
|
|
return false -- hide dialog from player
|
||
|
|
end
|
||
|
|
end, 20) -- priority 20
|
||
|
|
end
|
||
|
|
```
|
||
|
|
|
||
|
|
Key points:
|
||
|
|
- Dialog IDs are hardcoded SA-MP server values — find them by logging all dialogs first
|
||
|
|
- Always use `fw.timer_once()` for dialog responses to avoid blocking the game thread
|
||
|
|
- Return `false` to hide the dialog from the player
|
||
|
|
- Use `fw.unlock_dialog()` after a chain of automated dialogs to unblock the dialog system
|
||
|
|
- Dialog text uses Win-1251 encoding — use hex escapes for Cyrillic matching (e.g. `\xc2\xea\xeb\xfe\xf7` for "Включ")
|
||
|
|
|
||
|
|
## Error Handling
|
||
|
|
|
||
|
|
Module errors are caught by the framework via `pcall()`. If a module crashes:
|
||
|
|
- Error is logged via `fw.log("ERROR", ...)`
|
||
|
|
- Toast notification appears in-game
|
||
|
|
- Error is stored and visible in admin panel
|
||
|
|
- Module continues to be loaded (other handlers still work)
|
||
|
|
|
||
|
|
Best practices:
|
||
|
|
- Use `pcall()` for operations that might fail (JSON decode, game API calls)
|
||
|
|
- Always validate API body data before using it
|
||
|
|
- Log meaningful error messages with context
|
||
|
|
- Implement `M.unload()` to clean up state (cancel timers, reset globals)
|
||
|
|
|
||
|
|
## Hot Reloading
|
||
|
|
|
||
|
|
Modules can be reloaded at runtime via the admin panel or API:
|
||
|
|
- **Reload**: calls `unload()`, removes all API handlers and event interceptors owned by module, then loads fresh
|
||
|
|
- **Reset**: same as reload but also deletes all module DB data and clears state
|
||
|
|
|
||
|
|
On reload, all API handlers and event interceptors registered by the module are automatically removed before re-init. This means modules don't need to manually clean up handlers.
|
||
|
|
|
||
|
|
Things that are NOT auto-cleaned on reload:
|
||
|
|
- Global variables (avoid using them)
|
||
|
|
- SA-MP chat commands (registered once, cannot be unregistered)
|
||
|
|
- Running `lua_thread` coroutines from `fw.timer_once()`
|