call this version 0.0.3
[minetest-xpmod:xp.git] / init.lua
1 -- Copyright 2012 Andrew Engelbrecht.
2 -- This file is licensed under the WTFPL.
3
4 ----------------------------------
5
6 -- Some variables you can change:
7 ----------------------------------
8
9 -- How often (in seconds) xplevels file saves
10 local save_delta = 10
11 -- Max level a player can become; set to 0 for no level cap
12 local max_level = 32
13
14 ----------------------------------
15
16 -- some mod variables:
17 ----------------------------------
18
19 local xpmodver = "0.0.3"
20 local ffver = 1
21 local ffversupport = {1}
22 local file_name = "xplevels"
23
24 local xplevels_file = minetest.get_worldpath() .. "/" .. file_name
25 local xplevels = {}
26 local playert = {}
27 -- dynamically updated to note changes since last save to file:
28 local changed = false
29 -- automatically populated with list of xp needed to gain next level
30 local lptable = nil
31
32 ----------------------------------
33
34 -- function definitions:
35 ----------------------------------
36
37 -- prints out some default error text, the text passed as a parameter.
38 -- then the function kills the server.
39 local function parseerr(text)
40     print("")
41     print("*** xp module error:")
42     print("*** error parsing /"..file_name.." file!")
43     print("***")
44     print("*** "..text)
45     print("***")
46     print("")
47     os.exit()
48 end
49
50 -- extracts xp/level info per skill type for each user
51 -- pass it a line from the xplevels_file.
52 -- it returns the user name and a table of their stats
53 local function parselinev1(line)
54     local s,e,ign,spaceonly,laste,name,statstr,stkind,stlevel,stxp,badskl,test
55     local stats = {}
56
57     -- grabs any space or or an empty string on a blank line
58     ign,ign,spaceonly = string.find(line, "^(%s*)$")
59     if spaceonly ~= nil then
60         -- this is an empty line
61         return nil, nil
62     end
63
64     -- grabs text before the space
65     s,e,name = string.find(line, "^([^%s]+)%s")
66     if name == nil then
67         parseerr("missing space after user name:\n"..line)
68     end
69
70     laste = e
71     repeat
72         -- grabs the next set of text in parenthesis:
73         s,e,statstr = string.find(line, "^%s-%((.-)%)", e + 1)
74
75         if statstr ~= nil then
76             laste = e
77             -- grabs all the data in the parenthesis:
78             ign,ign,stkind,stlevel,stxp = string.find(statstr, "^([^%s]+): level: (%d+) xp: (%d+)$")
79
80             if stxp == nil then
81                 parseerr("bad text between parenthesis:\n"..statstr.."\n*** in this bad line:\n"..line)
82             end
83
84             stats[stkind] = {xp = tonumber(stxp), level = tonumber(stlevel)}
85         end
86     until statstr == nil
87
88     -- there should only be white space at the end of the line:
89     ign,ign,test = string.find(line, "^%s*(.-)$", laste + 1)
90     if test ~= "" then
91         -- finds the first string of text without parenthesis
92         ign,ign,badskl = string.find(line, "^%s*([^%s]+).*$", laste + 1)
93         parseerr("skill\n"..badskl.."\n*** lacks parenthesis in this line:\n"..line)
94     end
95
96     return name, stats
97 end
98
99 -- checks the version and format version string.
100 -- these signify the version of the xp mod that made the file,
101 -- and the formating style that was used.
102 -- pass it the first line of the xplevels_file
103 -- returns a pointer to the appropriate parser function
104 local function checkfver(line)
105     local s,e,ver,fmt,i,v
106
107     -- grabs the version, and the format version
108     s,e,ver,fmt = string.find(line, "-- Version: ([%d%a%.%-]+) File Format: (%d+)%s-$")
109
110     if ver == nil or fmt == nil then
111         print("*** xp module error: malformed Version string in /"..file_name.." line 1:")
112         print(line)
113         os.exit()
114     end
115
116     if tonumber(fmt) == 1 then
117         return parselinev1
118     else
119         print("*** xp module error: unsupported file format in /"..file_name.." file: "..fmt)
120         print("*** the version of this mod is: "..xpmodver)
121         print("*** this version only supports these format versions: ")
122         for i,v in ipairs(ffversupport) do
123             print(v)
124         end
125         print("***")
126         os.exit()
127     end
128 end
129
130 -- read in the xp and levels for each player listed in the xplevels_file
131 -- store the stats in xplevels, the table.
132 local function loadxp()
133     local input, line, parsel, name, stats
134
135     input = io.open(xplevels_file, "r")
136
137     if input ~= nil then
138         line = input:read("*l")
139
140         if line ~= nil then
141             parsel = checkfver(line)
142
143             line = input:read("*l")
144             while line ~= nil do
145
146                 name, stats = parsel(line)
147                 if name ~= nil then
148                     xplevels[name] = stats
149                 end
150
151                 line = input:read("*l")
152             end
153         end
154         io.close(input)
155     end
156 end
157
158 local function get_time()
159     return os.time()
160 end
161
162 -- returns true if player has ever logged into the server
163 -- it's different than minetest.is_player() since it accepts the name as a string,
164 -- and because you don't need to run a function that returns a player obj only for *online* players.
165 local function is_player(name)
166     local file
167
168     if playert[name] == nil then
169         file = io.open(minetest.get_worldpath().."/players/"..name, "r")
170         if file ~= nil then
171             playert[name] = true
172             io.close(file)
173         else
174             playert[name] = false
175         end
176     end
177
178     return playert[name]
179 end
180
181 -- returns the number of xp required to gain the next level.
182 -- pass it the player's current level.
183 local function lastpoint(level)
184     local i
185
186     if lptable == nil then
187         lptable = {}
188         for i = 0,max_level - 1 do
189             lptable[i] = (i + 1)^2 * 10
190         end
191     end
192     return lptable[level]
193 end
194
195 -- returns true if a player is at the maximum level.
196 -- pass it the player's level for a given skill.
197 local function ismaxlvl(level)
198     return max_level ~= 0 and level >= max_level
199 end
200
201 -- initializes a player's experience points and level for a given skill
202 local function initpxp(name, skill)
203     if xplevels[name] == nil then
204         xplevels[name] = {}
205     end
206     if xplevels[name][skill] == nil then
207         xplevels[name][skill] = {xp = 0, level = 0}
208     end
209 end
210
211 -- gives npoints additional xp points for the player in the specified skillset
212 local function gainxp(name, skill, npoints)
213     local stat, appnd, gainedlevel
214
215     if npoints < 0 then
216         return "negative"
217     end
218
219     if is_player(name) == false then
220         return "non-player"
221     end
222
223     initpxp(name, skill)
224     stat = xplevels[name][skill]
225
226     if ismaxlvl(stat.level) then
227         return "level-max"
228     end
229
230     -- the file needs to be updated in the future:
231     changed = true
232
233     gainedlevel = false
234     while npoints > 0 do
235
236         if stat.xp + npoints < lastpoint(stat.level) then
237             stat.xp = stat.xp + npoints
238             if gainedlevel == true then
239                 break
240             else
241                 return "gained-xp"
242             end
243         else
244             npoints = npoints - (lastpoint(stat.level) - stat.xp)
245
246             stat.level = stat.level + 1
247             stat.xp = 0
248
249             if ismaxlvl(stat.level) then
250                 appnd = " (max)"
251                 break
252             else
253                 appnd = ""
254             end
255
256             gainedlevel = true
257         end
258     end
259     minetest.chat_send_player(name, "Welcome to level "..stat.level.." in "..skill.."!"..appnd)
260     minetest.log("action", "xp mod: "..name.." gained level "..stat.level.." in "..skill..appnd..".")
261
262     return "gained-level"
263 end
264
265 -- prints the level and xp for a player in the specified skillset.
266 -- if no skillset is selected, then stats for every skillset are printed.
267 -- output goes to the player's chat console.
268 local function printxp(name, skill)
269     local i,v,stat,appnd
270
271     if xplevels[name] == nil then
272         minetest.chat_send_player(name, "You have no experience in anything.")
273         return
274     end
275
276     if skill == "" then
277         for i,v in pairs(xplevels[name]) do
278             if i ~= "" then
279                 printxp(name, i)
280             end
281         end
282     else
283         stat = xplevels[name][skill]
284         if stat == nil then
285             minetest.chat_send_player(name, "You don't have experience in '"..skill.."'. (check your spelling.)")
286         else
287             initpxp(name, skill)
288
289             if ismaxlvl(stat.level) then
290                 appnd = ". (max)"
291             else
292                 appnd = ", plus "..stat.xp.."/"..lastpoint(stat.level).." experience points."
293             end
294             minetest.chat_send_player(name, "You are level "..stat.level.." in "..skill..appnd)
295         end
296     end
297 end
298
299 -- save user xp and levels to xplevels_file
300 local function savetofile()
301     local line, output, i, v, j, w
302
303     output = io.open(xplevels_file, "w")
304
305     output:write("-- Version: "..xpmodver.." File Format: "..ffver.."\n")
306
307     for i, v in pairs(xplevels) do
308         line = ""
309         for j, w in pairs(v) do
310             line = line.." ("..j..": level: "..w.level.." xp: "..w.xp..")"
311         end
312         output:write(i..line.."\n")
313     end
314
315     io.close(output)
316 end
317
318 -- returns the probability of getting a drop, based on a player's level
319 -- if level_max is set to the default of 32, then:
320 -- at level 0, the prob. is about 1/100,000.
321 -- at level_max, it's about 1/1,000.
322 local function dropprob(level)
323     return (level + 3)^2 / (max_level + 3)^2 / 1000
324 end
325
326 -- gives a mese block to player named 'name'.
327 -- if thier inventory is full, then it falls on the ground.
328 local function givemese(name)
329     local player, pos
330     player = minetest.env:get_player_by_name(name)
331
332     initpxp(name, 'digging')
333     if xplevels[name].digging.level >= 2 then
334         inv = player:get_inventory()
335
336         if inv:room_for_item('main', 'default:mese') then
337             inv:add_item('main', 'default:mese')
338         else
339             pos = player:getpos()
340             pos.y = pos.y + 1.5
341             minetest.spawn_item(pos, 'default:mese')
342         end
343     end
344 end
345
346 ----------------------------------
347
348 -- callback registration:
349 ----------------------------------
350
351 -- minetest.register_privilege("givexp", "Can give xp to others with /givexp"
352
353 -- it raises the xp of a player by the specified number of points.
354 minetest.register_chatcommand("givexp", {
355     params = "<username> <skill> <num-xp>",
356     description = "Give a player experience points",
357     -- privs = {givexp=true},
358     privs = {},
359     func = function(caller, param)
360         local ign, name, skill, numxp, result
361
362         -- matches two words: the name and the skill
363         ign,ign,name,skill,numxp = string.find(param, "^([^%s]+)%s+([^%s]+)%s+(%d+)%s*$")
364
365         if numxp == nil then
366             minetest.chat_send_player(caller, "Incorrect usage. See: /help givexp")
367         else
368             result = gainxp(name,skill,tonumber(numxp))
369             if result == "non-player" then
370                 minetest.chat_send_player(caller, name.." isn't a player.")
371             elseif result == "level-max" then
372                 minetest.chat_send_player(caller, name.."'s "..skill.." level is already maxed-out.")
373             elseif result == "gained-xp" or result == "gained-level" then
374                 minetest.chat_send_player(caller, name.." is now level "..xplevels[name][skill].level.." plus "..xplevels[name][skill].xp.." xp in "..skill..".")
375                 minetest.log("action", "xp mod: ("..caller.." gave "..name.." "..numxp.." xp in "..skill..".)")
376             end
377         end
378     end,
379 })
380
381 -- shows the xp for the calling player, for a given skill, or if none is listed, then all of them.
382 minetest.register_chatcommand("showxp", {
383     params = "<skill (opt)>",
384     description = "Show your experience points",
385     -- privs = {xp=true},
386     privs = {},
387     func = printxp,
388 })
389
390 -- every time a player places an object, they get one extra xp for this skill
391 minetest.register_on_placenode(function(pos, newnode, placer, oldnode)
392     local name = placer:get_player_name()
393     gainxp(name, "construction", 1)
394 end)
395
396 -- every time a player digs an object, they get one extra xp for this skill
397 -- also, for now, the player gets mese somewhat randomly, based on level.
398 minetest.register_on_dignode(function(pos, node, player)
399     local name = player:get_player_name()
400     gainxp(name, "digging", 1)
401     if math.random() < dropprob(xplevels[name].digging.level) then
402         givemese(name)
403     end
404 end)
405
406 -- every time a player joins the game, add their name to the list of existing players.
407 -- if their player file wasn't readable, this works around the issue. also, if this is a new player,
408 -- then they will be added to the table even if they were previously marked as non-existent.
409 minetest.register_on_joinplayer(function(player)
410     local name = player:get_player_name()
411     playert[name] = true
412 end)
413
414 local delta = 0
415 -- every save_delta seconds, save the xp and levels, if they have changed for one or more players
416 minetest.register_globalstep(function(dtime)
417     delta = delta + dtime
418     -- save it every <save_delta> seconds
419     if delta > save_delta then
420         delta = delta - save_delta
421         if changed then
422             savetofile()
423             changed = false
424         end
425     end
426 end)
427
428 ----------------------------------
429
430 -- execute at the start of the game:
431 ----------------------------------
432
433 -- before this file is done being read, this function must be called
434 -- to load player xp and levels for each skill
435 loadxp()
436
437 -- Initialize the pseudo random number generator
438 math.randomseed( os.time() )
439 math.random(); math.random(); math.random()
440 -- done. :-)
441
442