Skip to content
Snippets Groups Projects
PlayerProfile.lua 20.7 KiB
Newer Older
dg's avatar
dg committed
-- Copyright (C) 2009, 2010, 2011 Nicolas Casalini
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program.  If not, see <http://www.gnu.org/licenses/>.
--
-- Nicolas Casalini "DarkGod"
-- darkgod@te4.org

require "engine.class"
local http = require "socket.http"
local url = require "socket.url"
local ltn12 = require "ltn12"
local lanes = require "lanes"
dg's avatar
dg committed
local Dialog = require "engine.ui.Dialog"
dg's avatar
dg committed
local UserChat = require "engine.UserChat"
require "Json2"

------------------------------------------------------------
-- some simple serialization stuff
------------------------------------------------------------
local function basicSerialize(o)
	if type(o) == "number" or type(o) == "boolean" then
		return tostring(o)
	elseif type(o) == "function" then
		return string.format("loadstring(%q)", string.dump(o))
	else   -- assume it is a string
		return string.format("%q", o)
	end
end

local function serialize_data(outf, name, value, saved, filter, allow, savefile, force)
	saved = saved or {}       -- initial value
	outf(name, " = ")
	if type(value) == "number" or type(value) == "string" or type(value) == "boolean" or type(value) == "function" then
		outf(basicSerialize(value), "\n")
	elseif type(value) == "table" then
			saved[value] = name   -- save name for next time
			outf("{}\n")     -- create a new table

			for k,v in pairs(value) do      -- save its fields
				local fieldname
				fieldname = string.format("%s[%s]", name, basicSerialize(k))
				serialize_data(outf, fieldname, v, saved, {new=true}, false, savefile, false)
			end
	else
		error("cannot save a " .. type(value) .. " ("..name..")")
	end
end

local function serialize(data)
	local tbl = {}
	local outf = function(...) for i,str in ipairs{...} do table.insert(tbl, str) end end
	for k, e in pairs(data) do
		serialize_data(outf, tostring(k), e)
	end
	return table.concat(tbl)
end
------------------------------------------------------------


--- Handles the player profile, possibly online
module(..., package.seeall, class.make)

dg's avatar
dg committed
	self.chat = UserChat.new()
	self.generic = {}
	self.modules = {}
	self.stats_fields = {}
	local checkstats = function(self, field) return self.stats_fields[field] end
	self.config_settings =
	{
		[checkstats]     = { invalid = { read={online=true}, write="online" }, valid = { read={online=true}, write="online" } },
		["^allow_build$"] = { invalid = { read={offline=true,online=true}, write="offline" }, valid = { read={offline=true,online=true}, write="online" } },
		["^achievements$"] = { invalid = { read={offline=true,online=true}, write="offline" }, valid = { read={online=true}, write="online" } },

	if self.generic.online and self.generic.online.login and self.generic.online.pass then
		self.login = self.generic.online.login
		self.pass = self.generic.online.pass
		self:tryAuth()
		self:waitFirstAuth()
	end
end

function _M:addStatFields(...)
	for i, f in ipairs{...} do
		self.stats_fields[f] = true
	end
end

function _M:loadData(f, where)
	setfenv(f, where)
	local ok, err = pcall(f)
	if not ok and err then print("Error executing data", err) end
function _M:mountProfile(online, module)
	-- Create the directory if needed
	local restore = fs.getWritePath()
	fs.setWritePath(engine.homepath)
	fs.mkdir(string.format("/profiles/%s/generic/", online and "online" or "offline"))
	if module then fs.mkdir(string.format("/profiles/%s/modules/%s", online and "online" or "offline", module)) end

	local path = engine.homepath.."/profiles/"..(online and "online" or "offline")
	fs.mount(path, "/current-profile")
	print("[PROFILE] mounted ", online and "online" or "offline", "on /current-profile")
	fs.setWritePath(path)

	return restore
end
function _M:umountProfile(online, pop)
	local path = engine.homepath.."/profiles/"..(online and "online" or "offline")
	fs.umount(path)
	print("[PROFILE] unmounted ", online and "online" or "offline", "from /current-profile")
-- Define the fields that are sync'ed online, and how they are sync'ed
local generic_profile_defs = {
	firstrun = {nosync=true, {firstrun="number"}, receive=function(data, save) save.firstrun = data.firstrun end },
	online = {nosync=true, {login="string:40", pass="string:40"}, receive=function(data, save) save.login = data.login save.pass = data.pass end },
	modules_played = { {name="index:string:30"}, {time_played="number"}, receive=function(data, save) max_set(save, data.name, data, "time_played") end, export=function(env) for k, v in pairs(env) do add{name=k, time_played=v} end end },
	modules_loaded = { {name="index:string:30"}, {nb="number"}, receive=function(data, save) max_set(save, data.name, data, "nb") end, export=function(env) for k, v in pairs(env) do add{name=k, nb=v} end end },
}

--- Loads profile generic profile from disk
-- Generic profile is always read from the "online" profile
function _M:loadGenericProfile()
	-- Delay when we are currently saving
	if savefile_pipe and savefile_pipe.saving then savefile_pipe:pushGeneric("loadGenericProfile", function() self:loadGenericProfile() end) return end

	local pop = self:mountProfile(true)
	local d = "/current-profile/generic/"
	for i, file in ipairs(fs.list(d)) do
		if file:find(".profile$") then
			local f, err = loadfile(d..file)
			if not f and err then
				print("Error loading data profile", file, err)
			else
				local field = file:gsub(".profile$", "")
				self.generic[field] = self.generic[field] or {}
				self:loadData(f, self.generic[field])
			end
	self:umountProfile(true, pop)
end

--- Check if we can load this field from this profile
function _M:filterLoadData(online, field)
	local ok = false
	for f, conf in pairs(self.config_settings) do
		local try = false
		if type(f) == "string" then try = field:find(f)
		elseif type(f) == "function" then try = f(self, field) end
		if try then
			local c
			if self.hash_valid then c = conf.valid
			else c = conf.invalid
			end
			if not c then break end

			c = c.read
			if not c then break end
			if online and c.online then ok = true
			elseif not online and c.offline then ok = true
			end
			break
		end
	end
	print("[PROFILE] filtering load of ", field, " from profile ", online and "online" or "offline", "=>", ok and "allowed" or "disallowed")
	return ok
end

--- Return if we should save this field in the online or offline profile
function _M:filterSaveData(field)
	local online = false
	for f, conf in pairs(self.config_settings) do
		local try = false
		if type(f) == "string" then try = field:find(f)
		elseif type(f) == "function" then try = f(self, field) end
		if try then
			local c
			if self.hash_valid then c = conf.valid
			else c = conf.invalid
			end
			if not c then break end

			c = c.write
			if not c then break end

			if c == "online" then online = true else online = false end
			break
		end
	end
	print("[PROFILE] filtering save of ", field, " to profile ", online and "online" or "offline")
	return online
end

--- Loads profile module profile from disk
function _M:loadModuleProfile(short_name, mod_def)
	if short_name == "boot" then return end

	-- Delay when we are currently saving
	if savefile_pipe and savefile_pipe.saving then savefile_pipe:pushGeneric("loadModuleProfile", function() self:loadModuleProfile(short_name) end) return end

	local function load(online)
		local pop = self:mountProfile(online, short_name)
		local d = "/current-profile/modules/"..short_name.."/"
		self.modules[short_name] = self.modules[short_name] or {}
		for i, file in ipairs(fs.list(d)) do
			if file:find(".profile$") then
				local field = file:gsub(".profile$", "")

				if self:filterLoadData(online, field) then
					local f, err = loadfile(d..file)
					if not f and err then
						print("Error loading data profile", file, err)
					else
						self.modules[short_name][field] = self.modules[short_name][field] or {}
						self:loadData(f, self.modules[short_name][field])
					end
				end
	load(false) -- Load from offline profile
	load(true) -- Load from online profile

	self.mod = self.modules[short_name]
	self.mod_name = short_name

	self:getConfigs(short_name, nil, mod_def)
	self:syncOnline(short_name, mod_def)
function _M:saveGenericProfile(name, data, no_sync, nowrite)
	-- Delay when we are currently saving
	if savefile_pipe and savefile_pipe.saving then savefile_pipe:pushGeneric("saveGenericProfile", function() self:saveGenericProfile(name, data, nosync) end) return end

	if not generic_profile_defs[name] then print("[PROFILE] refusing unknown generic data", name) return end
	profile.generic[name] = profile.generic[name] or {}
	local dataenv = profile.generic[name]
	local f = generic_profile_defs[name].receive
	setfenv(f, {
		inc_set=function(dataenv, key, data, dkey)
			local v = data[dkey]
			if type(v) == "number" then
			elseif type(v) == "table" and v[1] == "inc" then v = (dataenv[key] or 0) + v[2]
			end
			dataenv[key] = v
			data[dkey] = v
		end,
		max_set=function(dataenv, key, data, dkey)
			local v = data[dkey]
			if type(v) == "number" then
			elseif type(v) == "table" and v[1] == "inc" then v = (dataenv[key] or 0) + v[2]
			end
			v = math.max(v, dataenv[key] or 0)
			dataenv[key] = v
			data[dkey] = v
		end,
	})
	f(data, dataenv)

	if not nowrite then
		local pop = self:mountProfile(true)
		local f = fs.open("/generic/"..name..".profile", "w")
		f:write(serialize(dataenv))
		f:close()
		self:umountProfile(true, pop)
	end
	if not nosync and not generic_profile_defs[name].no_sync then self:setConfigs("generic", name, data) end
function _M:saveModuleProfile(name, data, nosync, nowrite)
	if module == "boot" then return end
	if not game or not game.__mod_info.profile_defs then return end
	-- Delay when we are currently saving
	if savefile_pipe and savefile_pipe.saving then savefile_pipe:pushGeneric("saveModuleProfile", function() self:saveModuleProfile(name, data, nosync) end) return end
	profile.mod[name] = profile.mod[name] or {}
	local dataenv = profile.mod[name]
	local f = game.__mod_info.profile_defs[name].receive
	setfenv(f, {
		inc_set=function(dataenv, key, data, dkey)
			local v = data[dkey]
			if type(v) == "number" then
			elseif type(v) == "table" and v[1] == "inc" then v = (dataenv[key] or 0) + v[2]
			end
			dataenv[key] = v
			data[dkey] = v
		end,
		max_set=function(dataenv, key, data, dkey)
			local v = data[dkey]
			if type(v) == "number" then
			elseif type(v) == "table" and v[1] == "inc" then v = (dataenv[key] or 0) + v[2]
			end
			v = math.max(v, dataenv[key] or 0)
			dataenv[key] = v
			data[dkey] = v
		end,
	})
	f(data, dataenv)

	if not nowrite then
		local online = self:filterSaveData(name)
		local pop = self:mountProfile(online, module)
		local f = fs.open("/modules/"..module.."/"..name..".profile", "w")
		f:write(serialize(dataenv))
	if not nosync and not game.__mod_info.profile_defs[name].no_sync then self:setConfigs(module, name, data) end
dg's avatar
dg committed
function _M:checkFirstRun()
	local result = self.generic.firstrun
	if not result then
		self:saveGenericProfile("firstrun", {firstrun=os.time()})
dg's avatar
dg committed
	end
	return result
end

dg's avatar
dg committed
function _M:performlogin(login, pass)
dg's avatar
dg committed
	self.login=login
	self.pass=pass
	print("[ONLINE PROFILE] attempting log in ", self.login)
dg's avatar
dg committed
	self.auth_tried = nil
dg's avatar
dg committed
	self:tryAuth()
dg's avatar
dg committed
	self:waitFirstAuth()
dg's avatar
dg committed
	if (profile.auth) then
		self:saveGenericProfile("online", {login=login, pass=pass})
dg's avatar
dg committed
		self:getConfigs("generic")
		self:syncOnline("generic")
	end
end

-----------------------------------------------------------------------
dg's avatar
dg committed
-- Events from the profile thread
-----------------------------------------------------------------------

dg's avatar
dg committed
function _M:waitEvent(name, cb, wait_max)
	-- Wait anwser, this blocks thegame but cant really be avoided :/
	local stop = false
	local first = true
dg's avatar
dg committed
	local tries = 0
dg's avatar
dg committed
		if not first then
			core.display.forceRedraw()
			core.game.sleep(50)
		end
		local evt = core.profile.popEvent()
		while evt do
			if type(game) == "table" then evt = game:handleProfileEvent(evt)
			else evt = self:handleEvent(evt) end
--			print("==== waiting event", name, evt.e)
			if evt.e == name then
				stop = true
				cb(evt)
				break
			end
			evt = core.profile.popEvent()
		end
		first = false
dg's avatar
dg committed
		tries = tries + 1
		if wait_max and tries * 50 > wait_max then break end
	end
end

function _M:waitFirstAuth(timeout)
	if self.auth_tried and self.auth_tried >= 1 then return end
	if not self.waiting_auth then return end
	print("[PROFILE] waiting for first auth")
	local first = true
	while self.waiting_auth and timeout > 0 do
dg's avatar
dg committed
		if not first then
			core.display.forceRedraw()
			core.game.sleep(50)
		end
		local evt = core.profile.popEvent()
		while evt do
			if type(game) == "table" then game:handleProfileEvent(evt)
			else self:handleEvent(evt) end
			if not self.waiting_auth then break end
			evt = core.profile.popEvent()
		end
		first = false
		timeout = timeout - 1
	end
end

	self.waiting_auth = false
	self.auth_tried = (self.auth_tried or 0) + 1
	if e.ok then
		self.auth = e.ok:unserialize()
		print("[PROFILE] Main thread got authed", self.auth.name)
dg's avatar
dg committed
		self:getConfigs("generic", function(e) self:syncOnline(e.module) end)
dg's avatar
dg committed
	if e.news and self.evt_cbs.GetNews then
		self.evt_cbs.GetNews(e.news:unserialize())
		self.evt_cbs.GetNews = nil
	end
end

dg's avatar
dg committed
function _M:eventGetConfigs(e)
dg's avatar
dg committed
	local data = zlib.decompress(e.data):unserialize()
dg's avatar
dg committed
	local module = e.module
	if not data then print("[ONLINE PROFILE] get configs") return end
	for i = 1, #data do
		local val = data[i]
dg's avatar
dg committed

		if module == "generic" then
			self:saveGenericProfile(e.kind, val, true, i < #data)
dg's avatar
dg committed
		else
			self:saveModuleProfile(e.kind, val, true, i < #data)
dg's avatar
dg committed
		end
	end
	if self.evt_cbs.GetConfigs then self.evt_cbs.GetConfigs(e) self.evt_cbs.GetConfigs = nil end
end

dg's avatar
dg committed
function _M:eventPushCode(e)
dg's avatar
dg committed
	local f, err = loadstring(e.code)
	if not f then
--		core.profile.pushOrder("o='GetNews'")
	else
		pcall(f)
	end
dg's avatar
dg committed
end

function _M:eventChat(e)
	self.chat:event(e)
end

dg's avatar
dg committed
function _M:eventConnected(e)
	if game and type(game) == "table" and game.log then game.log("#YELLOW#Connection to online server established.") end
end

function _M:eventDisconnected(e)
	if game and type(game) == "table" and game.log then game.log("#YELLOW#Connection to online server lost, trying to reconnect.") end
end

--- Got an event from the profile thread
function _M:handleEvent(e)
	e = e:unserialize()
	if not e then return end
	if self["event"..e.e] then self["event"..e.e](self, e) end
	return e
end

-----------------------------------------------------------------------
dg's avatar
dg committed
-- Orders for the profile thread
-----------------------------------------------------------------------

function _M:getNews(callback)
	print("[ONLINE PROFILE] get news")
	self.evt_cbs.GetNews = callback
dg's avatar
dg committed
	core.profile.pushOrder("o='GetNews'")
	print("[ONLINE PROFILE] auth")
	core.profile.pushOrder(table.serialize{o="Login", l=self.login, p=self.pass})
dg's avatar
dg committed
function _M:logOut()
	core.profile.pushOrder(table.serialize{o="Logoff"})
	profile.generic.online = nil
	profile.auth = nil
dg's avatar
dg committed
	local pop = self:mountProfile(true)
	fs.delete("/generic/online.profile")
	self:umountProfile(true, pop)
dg's avatar
dg committed
end

function _M:getConfigs(module, cb, mod_def)
dg's avatar
dg committed
	if not self.auth then return end
	self.evt_cbs.GetConfigs = cb
	if module == "generic" then
		for k, _ in pairs(generic_profile_defs) do
			if not _.no_sync then
				core.profile.pushOrder(table.serialize{o="GetConfigs", module=module, kind=k})
			end
		end
	else
		for k, _ in pairs((mod_def or game.__mod_info).profile_defs) do
			if not _.no_sync then
				core.profile.pushOrder(table.serialize{o="GetConfigs", module=module, kind=k})
			end
		end
	end
function _M:setConfigsBatch(v)
	core.profile.pushOrder(table.serialize{o="SetConfigsBatch", v=v and true or false})
end

function _M:setConfigs(module, name, data)
	if not self.auth then return end
	if name == "online" then return end
	if module ~= "generic" then
		if not game.__mod_info.profile_defs then print("[PROFILE] saving config but no profile defs", module, name) return end
		if not game.__mod_info.profile_defs[name] then print("[PROFILE] saving config but no profile def kind", module, name) return end
	else
		if not generic_profile_defs[name] then print("[PROFILE] saving config but no profile def kind", module, name) return end
	end
	core.profile.pushOrder(table.serialize{o="SetConfigs", module=module, kind=name, data=zlib.compress(table.serialize(data))})
function _M:syncOnline(module, mod_def)
	if not self.auth then return end
	local sync = self.generic
	if module ~= "generic" then sync = self.modules[module] end
	if not sync then return end

	self:setConfigsBatch(true)
	if module == "generic" then
		for k, def in pairs(generic_profile_defs) do
			if not def.no_sync and def.export and sync[k] then
				local f = def.export
				local ret = {}
				setfenv(f, setmetatable({add=function(d) ret[#ret+1] = d end}, {__index=_G}))
				f(sync[k])
				for i, r in ipairs(ret) do
					core.profile.pushOrder(table.serialize{o="SetConfigs", module=module, kind=k, data=zlib.compress(table.serialize(r))})
				end
			end
		end
	else
		for k, def in pairs((mod_def or game.__mod_info).profile_defs) do
			if not def.no_sync and def.export and sync[k] then
				local f = def.export
				local ret = {}
				setfenv(f, setmetatable({add=function(d) ret[#ret+1] = d end}, {__index=_G}))
				f(sync[k])
				for i, r in ipairs(ret) do
					core.profile.pushOrder(table.serialize{o="SetConfigs", module=module, kind=k, data=zlib.compress(table.serialize(r))})
				end
			end
		end
	end
	self:setConfigsBatch(false)
dg's avatar
dg committed
end

function _M:checkModuleHash(module, md5)
	self.hash_valid = false
dg's avatar
dg committed
	if not self.auth then return nil, "no online profile active" end
dg's avatar
dg committed
	if config.settings.cheat then return nil, "cheat mode active" end
	if game and game:isTainted() then return nil, "savefile tainted" end
dg's avatar
dg committed
	core.profile.pushOrder(table.serialize{o="CheckModuleHash", module=module, md5=md5})

	self:waitEvent("CheckModuleHash", function(e) ok = e.ok end, 10000)
dg's avatar
dg committed

	if not ok then return nil, "bad game version" end
	print("[ONLINE PROFILE] module hash is valid")
	self.hash_valid = true
	return true
end

function _M:sendError(what, err)
	print("[ONLINE PROFILE] sending error")
	core.profile.pushOrder(table.serialize{o="SendError", login=self.login, what=what, err=err, module=game.__mod_info.short_name, version=game.__mod_info.version_name})
function _M:registerNewCharacter(module)
	if not self.auth or not self.hash_valid then return end
	local dialog = Dialog:simplePopup("Registering character", "Character is being registered on http://te4.org/") dialog.__showup = nil core.display.forceRedraw()

	core.profile.pushOrder(table.serialize{o="RegisterNewCharacter", module=module})
	local uuid = nil
	self:waitEvent("RegisterNewCharacter", function(e) uuid = e.uuid end, 10000)

	game:unregisterDialog(dialog)
	if not uuid then return end
	print("[ONLINE PROFILE] new character UUID ", uuid)
	return uuid
end

function _M:registerSaveChardump(module, uuid, title, tags, data)
	if not self.auth or not self.hash_valid then return end
dg's avatar
dg committed
	core.profile.pushOrder(table.serialize{o="SaveChardump",
		module=module,
		uuid=uuid,
		data=data,
		metadata=table.serialize{tags=tags, title=title},
	})
	print("[ONLINE PROFILE] saved character ", uuid)
end

dg's avatar
go  
dg committed
function _M:currentCharacter(module, title, uuid)
dg's avatar
dg committed
	if not self.auth then return end
	core.profile.pushOrder(table.serialize{o="CurrentCharacter",
		module=module,
dg's avatar
dg committed
		mod_short=(game and type(game)=="table") and game.__mod_info.short_name or "unknown",
dg's avatar
dg committed
		title=title,
		valid=self.hash_valid,
dg's avatar
go  
dg committed
		uuid=uuid,
dg's avatar
dg committed
	})
	print("[ONLINE PROFILE] current character ", title)
end

function _M:newProfile(Login, Name, Password, Email)
	print("[ONLINE PROFILE] profile options ", Login, Email, Name)
dg's avatar
dg committed
	core.profile.pushOrder(table.serialize{o="NewProfile2", login=Login, email=Email, name=Name, pass=Password})
	local id = nil
dg's avatar
dg committed
	self:waitEvent("NewProfile2", function(e) id = e.uid end)
dg's avatar
dg committed
	if not id then print("[ONLINE PROFILE] could not create") return end
	print("[ONLINE PROFILE] profile id ", id)
dg's avatar
dg committed
	self:performlogin(Login, Password)
dg's avatar
dg committed
end