Files and the web
Lesson 15.3
Config files are for settings. For everything else your mod wants to keep, like a high-score list, a cache or a log of what happened, use your own files. And with the player’s permission, your mod can ask a website for data. All of it stays inside the sandbox: Permissions explained the rules.
Your folder
Your mod lives in mods/<your mod>/. You may read every file in it, and write only in its data/ folder:
| Path | Read | Write |
|---|---|---|
scripts/main.lua, assets/..., clight.mod.json |
yes | no |
data/... |
yes | yes |
anything outside your folder (../, C:/...) |
no | no |
Paths are written with / and start inside your folder: "data/scores.json" means mods/<your mod>/data/scores.json. During clight run, data/ is the project’s build/run/<your mod>/data/, so your sources stay clean.
Reading and writing text
local ok, err = cl.fs.write("data/hello.txt", "Hello from my mod!")
print(ok, err) --> true nil
print(cl.fs.read("data/hello.txt")) --> Hello from my mod!
cl.fs.append("data/log.txt", "a player joined\n")
print(cl.fs.exists("data/log.txt")) --> true
Like Lua’s own io functions, the file functions don’t raise errors. When something goes wrong they return nil and a message:
local text, err = cl.fs.read("data/missing.txt")
print(text, err) --> nil not found: data/missing.txt
print(cl.fs.write("scripts/main.lua", "oops"))
--> nil fs_write: writes go to data/ ("data/scripts/main.lua")
print(cl.fs.read("../other_mod/secret.txt"))
--> nil path not allowed: ../other_mod/secret.txt
Writes are safe: the game writes a new file next to the old one and then swaps them, so a crash never leaves half a file.
Folders
cl.fs.mkdir("data/players")
for _, name in ipairs(cl.fs.list("data")) do print(name) end
--> hello.txt
--> log.txt
--> players/
local info = cl.fs.stat("data/log.txt")
print(info.kind, info.size) --> file 16
cl.fs.rename("data/log.txt", "data/old_log.txt")
cl.fs.remove("data/players", true) -- true: with everything inside
data/ can hold 256 MiB (cl.fs.used() and cl.fs.quota() tell you how much is in use), and files that could run as programs (.exe, .bat, .js, …) can’t be written at all.
Tables as JSON
Tables are easiest to keep as JSON:
local scores = cl.fs.read_json("data/scores.json") or {}
cl.on("player.join", function(e)
local name = e.player.name
scores[name] = (scores[name] or 0) + 1
cl.fs.write_json("data/scores.json", scores)
e.player:send_message("Visits: " .. scores[name])
end)
cl.json.encode(value) and cl.json.decode(text) do the same with strings. A table with the keys 1, 2, 3, … becomes a JSON list, any other table an object.
Asking a website
A mod may only talk to the websites it lists in its manifest, and only after the player allowed it:
"permissions": ["net.http:api.ruby-tools.example"]
Then:
cl.http.get("https://api.ruby-tools.example/v1/prices", function(res)
if not res.ok then
print("no prices:", res.error or res.status)
return
end
local prices = res.json()
print("a ruby costs", prices.ruby)
end)
The request runs in the background, so the game never waits for the website. Your function is called at the next tick with the answer: res.status (200 means OK, 0 means no answer came), res.ok, res.body (the text), res.json() and res.error (why it failed, like "timeout" or "not allowed: example.com").
To send data, use post (a table is sent as JSON):
cl.http.post("https://api.ruby-tools.example/v1/scores", { player = "Alex", score = 12 }, function(res)
print("saved:", res.status)
end)
And to download a file straight into data/:
cl.http.request{
url = "https://api.ruby-tools.example/v1/pack.zip",
save_to = "data/pack.zip",
timeout = 30,
on_done = function(res) print("downloaded:", res.ok) end,
}
Only https:// works (plain http:// only to your own computer, localhost), a request takes at most a minute, and an answer at most 64 MiB. A download is a plain file: nothing the game downloads is ever run.
Asking for permission later
Put a website your mod can live without into optional_permissions, and ask (on the client) when the player first uses the feature:
cl.on("lifecycle.client_setup", function()
local key = cl.keybind.register{ id = "my_mod:prices", default_key = "key.keyboard.p", category = "key.categories.my_mod" }
cl.on("tick.client_post", function()
while key:consume_click() do
if cl.permission("net.http:api.ruby-tools.example") then
show_prices()
else
cl.request_permission("net.http:api.ruby-tools.example") -- opens the mod's Permissions screen
end
end
end)
end)
cl.on("mod.permission_changed", function(e)
if e.granted then show_prices() end
end)
cl.request_permission returns true when it is already allowed, nil while the player decides, and false when the player said no before (they can change it in Mods, then your mod, then Permissions).
Try it
- Count how often each player joined and keep the counts in
data/visits.json. Print the top three in a/visitscommand. - Try to write
scripts/hacked.luaanddata/tool.exe. What does each call return?
Show a solution
local visits = cl.fs.read_json("data/visits.json") or {}
cl.on("player.join", function(e)
local name = e.player.name
visits[name] = (visits[name] or 0) + 1
cl.fs.write_json("data/visits.json", visits)
end)
cl.on("server.command_register", function()
cl.cmd.register(cl.cmd.literal("visits"):executes(function(ctx)
local list = {}
for name, n in pairs(visits) do list[#list + 1] = { name = name, n = n } end
table.sort(list, function(a, b) return a.n > b.n end)
for i = 1, math.min(3, #list) do ctx:success(list[i].name .. ": " .. list[i].n) end
return 1
end))
end)
print(cl.fs.write("scripts/hacked.lua", "x")) --> nil fs_write: writes go to data/ ...
print(cl.fs.write("data/tool.exe", "x")) --> nil fs_write: path not allowed: data/tool.exe
Recap: cl.fs reads your folder and writes data/, cl.json turns tables into text and back, and cl.http talks to the websites the player allowed, with the answer at the next tick.