
-------------------------------------------------------------------------
-- ANTI PRIVS detects if a custom client without interacting "zips" around the map
-- Copyright (c) 2021 mt-mods/BuckarooBanzay MIT
-- Copyright 2020-2023 improvements and few fixes, mckaygerhard CC-BY-SA-NC 4.0
-------------------------------------------------------------------------

-- per-player data
local player_data = {}

local is_player = function(player)
	-- a table being a player is also supported because it quacks sufficiently
	-- like a player if it has the is_player function
	local t = type(player)
	return (t == "userdata" or t == "table") and type(player.is_player) == "function"
end

-- obtain player data cheats, improved respect beowulf with missing checks and methods
local function get_player_data(name)
	local player = minetest.get_player_by_name(name)
	if not player_data[name] then
		player_data[name] = {
			fliyers = 0, -- number of checks for fly ( added only with governing)
			strikes = 0, -- number of "strikes" (odd movements)
			checked = 0, -- number of checks
			pre_pos = player:get_pos() -- position track (missing in beowulf as bug)
		}
	end
	return player_data[name]
	-- WARNING pos its always current so checked must be after an interval using callbacks
end

-- clear player data for cheats
local function track_player_clear(player)
	if is_player(player) then
		if player:get_player_name() then
			player_data[player:get_player_name()] = nil
		end
	end
end

-- store player data cheats strikes and checks
local function track_player(player)
	if not is_player(player) then return end
	local name = player:get_player_name()
	local data = get_player_data(name)
	local pos = player:get_pos()

	if data.pre_pos then
		-- compare positions
		local d = vector.distance(pos, data.pre_pos)
		if d > 200 then
			data.strikes = data.strikes + 1
		end
		data.checked = data.checked + 1
	end

	if data.checked >= 10 then
		-- check strike-count after 10 movement checks
		if data.strikes > 8 then
			-- suspicious movement, log it
			-- TODO: if this doesn't yield any false-positives, add a kick/ban option
			local msg = "suspicious movement detected for player: '" .. name .. "'"
			minetest.log("action", "[governing][beowulf] " .. msg)
		end

		-- reset counters
		data.checked = 0
		data.strikes = 0
	end

	-- store current position
	data.pre_pos = pos
end

-------------------------------------------------------------------------
-- ANTI CHEAT for MT4 and old MT5 engines by rnd
-- Copyright 2016 rnd  LGPL v3
-- Copyright 2020-2023 improvements and few fixes, mckaygerhard CC-BY-SA-NC 4.0
-------------------------------------------------------------------------
local cheat = {};
local version = "09/08/2017";

anticheatsettings = {};
anticheatsettings.moderators = {}
anticheatsettings.CHEAT_TIMESTEP = tonumber(minetest.settings:get("governing.timestep")) or 15; -- check timestep all players 
anticheatsettings.CHECK_AGAIN = tonumber(minetest.settings:get("governing.timeagain")) or 15; -- after player found in bad position check again after this to make sure its not lag, this should be larger than expected lag in seconds
anticheatsettings.STRING_MODERA = minetest.settings:get("governing.moderators") or "admin,singleplayer";
-- moderators list, those players can use cheat debug and will see full cheat message
for str in string.gmatch(anticheatsettings.STRING_MODERA, "([^,]+)") do table.insert(anticheatsettings.moderators, str) end


local CHEAT_TIMESTEP = anticheatsettings.CHEAT_TIMESTEP;
local CHECK_AGAIN = anticheatsettings.CHECK_AGAIN;
cheat.moderators = anticheatsettings.moderators;

bonemod = minetest.get_modpath("boneworld")

anticheatdb = {}; -- data about detected cheaters

cheat.suspect = "";
cheat.players = {}; -- temporary cheat detection db
cheat.message = "";
cheat.debuglist = {}; -- [name]=true -- who gets to see debug msgs

cheat.scan_timer = 0; -- global scan of players
cheat.stat_timer = 0; -- used to collect stats

cheat.nodelist = {};

cheat.timestep = CHEAT_TIMESTEP;
-- list of forbidden nodes
cheat.nodelist = {["default:stone"] = false, ["default:cobble"]= false, ["default:dirt"] = false, ["default:sand"]=false,["default:tree"]= false};


local punish_cheat = function(name)
	
	local player = minetest.get_player_by_name(name); 
	local ip = tostring(minetest.get_player_ip(name));
	
	if not player then return end
	local text=""; local logtext = "";
	
	if cheat.players[name].cheattype == 1 then
		text = "#anticheat: ".. name .. " was caught walking inside wall";
		logtext = os.date("%H:%M.%S").." #anticheat: ".. name .. " was caught walking inside wall at " .. minetest.pos_to_string(cheat.players[name].cheatpos);
		--player:set_hp(0);
	elseif cheat.players[name].cheattype == 2 then
		
		local gravity = player:get_physics_override().gravity;	if gravity<1 then return end
		logtext= os.date("%H:%M.%S").." #anticheat: ".. name .. " was caught flying at " .. minetest.pos_to_string(cheat.players[name].cheatpos);
		if cheat.players[name].cheatpos.y>5 then -- only above height 5 it directly damages flyer
			text = "#anticheat: ".. name .. " was caught flying";
			--player:set_hp(0);
		end
	end
	
	if text~="" then
		minetest.chat_send_all(text);
	end	
	
	if logtext~="" then
		minetest.log("action", logtext);
		cheat.message = logtext;
		
		anticheatdb[ip] = {name = name, msg = logtext};

		cheat.players[name].count=0; -- reset counter
		cheat.players[name].cheattype = 0;
	
		for namem,_ in pairs(cheat.moderators) do -- display full message to moderators
			minetest.chat_send_player(namem,logtext);
		end
	end
end

-- CHECKS


-- DETAILED NOCLIP CHECK
local check_noclip = function(pos) 
	local nodename = minetest.get_node(pos).name;
	local clear=true;
	if nodename ~= "air" then  -- check if forbidden material!
		clear = cheat.nodelist[nodename]; -- test clip violation
		if clear == nil then clear = true end
	end
	
	if not clear then -- more detailed check
		local anodes = minetest.find_nodes_in_area({x=pos.x-1, y=pos.y-1, z=pos.z-1}, {x=pos.x+1, y=pos.y+1, z=pos.z+1}, {"air"});
		if #anodes == 0 then return false end
		clear=true;
	end
	return clear;
end

-- DETAILED FLY CHECK
local check_fly = function(pos) -- return true if player not flying
	local fly = (minetest.get_node(pos).name=="air" and minetest.get_node({x=pos.x,y=pos.y-1,z=pos.z}).name=="air"); -- prerequisite for flying is this to be "air", but not sufficient condition
	if not fly then return true end;
	
	local anodes = minetest.find_nodes_in_area({x=pos.x-1, y=pos.y-1, z=pos.z-1}, {x=pos.x+1, y=pos.y, z=pos.z+1}, {"air"});
	if #anodes == 18 then -- player standing on air?
		return false
	else
		return true			
	end
end


local round = function (x)
	if x > 0 then 
		return math.floor(x+0.5) 
	else
		return -math.floor(-x+0.5) 
	end
end

--main check routine
local check_player = function(player)

	local name = player:get_player_name();
	local privs = minetest.get_player_privs(name).kick;if privs then return end -- dont check moderators
	
	local pos = player:getpos(); -- feet position
	pos.x = round(pos.x*10)/10;pos.z = round(pos.z*10)/10; -- less useless clutter
	pos.y = round(pos.y*10)/10; -- minetest buggy collision - need to do this or it returns wrong materials for feet position: aka magic number 0.498?????228
	if pos.y<0 then pos.y=pos.y+1 end -- weird, without this it fails to check feet block where y<0, it checks one below feet
	
	local nodename = minetest.get_node(pos).name;
	local clear=true;
	if nodename ~= "air" then  -- check if forbidden material!
		clear = cheat.nodelist[nodename]; -- test clip violation
		if clear == nil then clear = true end
	end
	
	local fly = (nodename=="air" and minetest.get_node({x=pos.x,y=pos.y-1,z=pos.z}).name=="air"); -- prerequisite for flying, but not sufficient condition

	if cheat.players[name].count == 0 then -- player hasnt "cheated" yet, remember last clear position
		cheat.players[name].clearpos = cheat.players[name].lastpos
	end

	
	-- manage noclip cheats
	if not clear then -- player caught inside walls
		local moved = (cheat.players[name].lastpos.x~=pos.x) or (cheat.players[name].lastpos.y~=pos.y) or (cheat.players[name].lastpos.z~=pos.z);
		if moved then -- if player stands still whole time do nothing
			if cheat.players[name].count == 0 then cheat.players[name].cheatpos = pos end -- remember first position where player found inside wall
			
			
			if cheat.players[name].count == 0 then
				minetest.after(CHECK_AGAIN+math.random(5),
					function()
						cheat.players[name].count = 0;
						if not check_noclip(pos) then
							punish_cheat(name)-- we got a cheater!
						else
							cheat.players[name].count = 0; -- reset
							cheat.players[name].cheattype = 0;
						end
					end
				)
			end
			
			if cheat.players[name].count == 0 then -- mark as suspect
				cheat.players[name].count = 1; 
				cheat.players[name].cheattype = 1;
			end

		end
	end
	
	-- manage flyers
	if fly then

		local fpos;
		fly,fpos = minetest.line_of_sight(pos, {x = pos.x, y = pos.y - 4, z = pos.z}, 1); --checking node maximal jump height below feet
		
		if fly then -- so we are in air, are we flying?
			
			if player:get_player_control().sneak then -- player sneaks, maybe on border?
				--search 18 nodes to find non air
				local anodes = minetest.find_nodes_in_area({x=pos.x-1, y=pos.y-1, z=pos.z-1}, {x=pos.x+1, y=pos.y, z=pos.z+1}, {"air"});
				if #anodes < 18 then fly = false end
			end	-- if at this point fly = true means player is not standing on border
		
			if pos.y>=cheat.players[name].lastpos.y and fly then -- we actually didnt go down from last time and not on border

				-- was lastpos in air too?
				local lastpos  =  cheat.players[name].lastpos;
				local anodes = minetest.find_nodes_in_area({x=lastpos.x-1, y=lastpos.y-1, z=lastpos.z-1}, {x=lastpos.x+1, y=lastpos.y, z=lastpos.z+1}, {"air"});
				if #anodes == 18 then fly = true else fly = false end
				
				if fly then -- so now in air above previous position, which was in air too?
				
					if cheat.players[name].count == 0 then cheat.players[name].cheatpos = pos end -- remember first position where player found "cheating"
					
					if cheat.players[name].count == 0 then
						minetest.after(CHECK_AGAIN,
							function()
								cheat.players[name].count = 0;
								if not check_fly(pos) then
									punish_cheat(name)-- we got a cheater!
								else
									cheat.players[name].count = 0; 
									cheat.players[name].cheattype = 0;
								end
							end
						)
					end
			
					if cheat.players[name].count == 0 then -- mark as suspect
						cheat.players[name].count = 1; 
						cheat.players[name].cheattype = 2;
					end
				end
				
			end
			
		end
	end

	cheat.players[name].lastpos = pos
end

	

minetest.register_globalstep(function(dtime)
	
	cheat.scan_timer = cheat.scan_timer + dtime
	
	
	-- GENERAL SCAN OF ALL PLAYERS
	if cheat.scan_timer>cheat.timestep then	
		
		
		cheat.stat_timer = cheat.stat_timer + cheat.timestep; 
		-- dig xp stats every 2 minutes
		if bonemod and cheat.stat_timer>120 then
			cheat.stat_timer = 0;
			local players = minetest.get_connected_players();
			for _,player in pairs(players) do
				local pname = player:get_player_name();
				if cheat.players[pname].stats.state == 1 then -- only if dig xp loaded to prevent anomalous stats
					if boneworld.digxp[pname] then
						local deltadig = cheat.players[pname].stats.digxp;
						cheat.players[pname].stats.digxp = boneworld.digxp[pname];
						deltadig = boneworld.digxp[pname]-deltadig;
						cheat.players[pname].stats.deltadig = deltadig;
						
						if deltadig>cheat.players[pname].stats.maxdeltadig then
							cheat.players[pname].stats.maxdeltadig = deltadig;
						end
						
						if deltadig>2 then -- unnaturally high deltadig
							local ip = tostring(minetest.get_player_ip(pname));
							local logtext = os.date("%H:%M.%S") .. " #anticheat: " .. pname .. " (ip " .. ip .. ") is mining resources too fast, deltadig " .. deltadig;
							anticheatdb[ip] = {name = pname, msg = logtext};
							minetest.log("action", logtext);
						end
						
					end
				end
			end
		end
		
		
		cheat.timestep = CHEAT_TIMESTEP + (2*math.random()-1)*2; -- randomize step so its unpredictable
		cheat.scan_timer=0;
		--local t = minetest.get_gametime();
		local players = minetest.get_connected_players();
		
		for _,player in pairs(players) do
			check_player(player);
		end
		
		for name,_ in pairs(cheat.debuglist) do -- show suspects in debug
			for _,player in pairs(players) do
				local pname = player:get_player_name();
				if cheat.players[pname].count>0 then
					minetest.chat_send_player(name, "name " .. pname .. ", cheat pos " .. minetest.pos_to_string(cheat.players[pname].cheatpos) .. " last clear pos " .. minetest.pos_to_string(cheat.players[pname].clearpos) .. " cheat type " .. cheat.players[pname].cheattype .. " cheatcount " .. cheat.players[pname].count );
				end
			end
		end
		
		
	end
end)

-- long range dig check

local check_can_dig = function(pos, digger) 

	local cpos = minetest.pos_to_string(pos) or "missing pos seems nul returned"
	local logtext = os.date("%H:%M.%S") .. "#anticheat: long range dig made by some entity, could be a player or huge tnt explotion at ".. cpos;
	if not digger then
		minetest.log("warning", "[governing] "..logtext)
		return
	end
	local p = digger:getpos();
	if p.y<0 then p.y=p.y+2 else p.y=p.y+1 end -- head position
	local dist = math.max(math.abs(p.x-pos.x),math.abs(p.y-pos.y),math.abs(p.z-pos.z));

	
	if dist>6 then -- here 5
		dist = math.floor(dist*100)/100;
		local pname = digger:get_player_name();
		logtext = os.date("%H:%M.%S") .. "#anticheat: long range dig " .. pname ..", distance " .. dist .. ", pos " .. cpos;
		for name,_ in pairs(cheat.debuglist) do -- show to all watchers
			minetest.chat_send_player(name,logtext)
			minetest.log("warning", "[governing] "..logtext)
		end
		local ip = tostring(minetest.get_player_ip(pname));
		anticheatdb[ip] = {name = pname, msg = logtext};
		return false
	end
	
	return true
end

local set_check_can_dig = function(name)
	local tabl = minetest.registered_nodes[name];
	if not tabl then return end
	tabl.can_dig = check_can_dig;
	minetest.override_item(name, {can_dig = check_can_dig})
	--minetest.register_node(":"..name, tabl);
end

local function is_player(player)
	if player then
		if type(player) == "userdata" or type(player) == "table" then return true end
	end
	return false
end

minetest.register_on_joinplayer(function(player)

	-- init stuff on player join
	if not is_player(player) then return end
	local name = player:get_player_name();
	if type(name) ~= "string" then return end
	local pos = player:getpos();

	-- no matter if are incomplete info, save most possible of and start recolection of stats
	if cheat.players[name] == nil then
		cheat.players[name]={count=0,cheatpos = pos, clearpos = pos, lastpos = pos, cheattype = 0}; -- type 0: none, 1 noclip, 2 fly
	end

	-- try to fill some stats or retrieve previously
	if cheat.players[name] and cheat.players[name].stats == nil then
		cheat.players[name].stats = {maxdeltadig=0,deltadig = 0,digxp = 0, state = 0}; -- various statistics about player: max dig xp increase in 2 minutes, current dig xp increase
		if bonemod then
			minetest.after(4, -- load digxp after boneworld loads it
				function() 
					if boneworld.xp then
						cheat.players[name].stats.digxp = boneworld.digxp[name] or 0;
						cheat.players[name].stats.state = 1;
					end
				end
			) --state 0 = stats not loaded yet
		end
	end

	-- check anticheat db for cheaters clients
	-- ===================================

	local ip = tostring(minetest.get_player_ip(name));
	local msgiplevelone = "";
	local msgipleveltwo = "";

	--check ip first try of info manually, later from player info
	if anticheatdb[ip] then 
		msgiplevelone = "#anticheat: welcome back detected cheater, ip = " .. ip .. ", name " .. anticheatdb[ip].name .. ", new name = " .. name;
	end;
	--check names from stats
	for ip,v in pairs(anticheatdb) do
		if v.name == name then 
			msgiplevelone = "#anticheat: welcome back detected cheater, ip = " .. ip .. ", name = newname =  " .. v.name;
			break;
		end
	end
	-- send detection msg before try to check info (cos info may be incomplete detection)
	if msgiplevelone~="" then
		minetest.after(1, function()
			for namemd,_ in pairs(cheat.moderators) do 
				minetest.chat_send_player(namemd,msgiplevelone);
			end
		end)
	end

end)

minetest.register_chatcommand("cchk", {
	privs = {
		interact = true,
		server = true
	},
	description = "cchk NAME,  checks if player is cheating in this moment",
	func = function(name, param)
		local privs = minetest.get_player_privs(name).privs;
		if not cheat.moderators[name] and not privs then return end

		local player = minetest.get_player_by_name(param);
		if not player then return end
		check_player(player);
		
		local ip = tostring(minetest.get_player_ip(param));
		local info = minetest.get_player_information(param)
		local msgm = "#anticheat detected a cheater with "..ip.." named "..param
		if info.version_string then
			local dfv = gapi.isdf(info.version_string)
			if dfv then
				msgm = msgm.." using "..info.version_string
				minetest.chat_send_player(param, "..... cheat");
				if minetest.settings:get_bool("beowulf.dfdetect.enable_kick", false) then
					minetest.kick_player(param, "Are you a cheater stupid user? change your cracked client for play")
				end
				minetest.chat_send_player(name,msgm); -- advertise moderators
				for namemd,_ in pairs(cheat.moderators) do 
					minetest.chat_send_player(namemd,msgm); -- advertise moderators
				end
				minetest.log(msgm)
			else
				msgm = "Still just suspicius for "..param.." at "..ip.." using "..info.version_string
				minetest.chat_send_player(name,msgm); -- advertise command executor
			end
		else
			for namemd,_ in pairs(cheat.moderators) do 
				minetest.chat_send_player(namemd,msgm); -- advertise moderators
			end
			minetest.log(msgm)
		end

		
		local players = minetest.get_connected_players();
		for name,_ in pairs(cheat.debuglist) do -- show suspects in debug
			for _,player in pairs(players) do
				local pname = player:get_player_name();
				if cheat.players[pname].count>0 then
					minetest.chat_send_player(name, "name " .. pname .. ", cheat pos " .. minetest.pos_to_string(cheat.players[pname].cheatpos) .. " last clear pos " .. minetest.pos_to_string(cheat.players[pname].clearpos) .. " cheat type " .. cheat.players[pname].cheattype .. " cheatcount " .. cheat.players[pname].count );
				end
			end
		end
	end
})


minetest.register_chatcommand("crep", { -- see cheat report
	privs = {
		interact = true
	},
	description = "crep 0/1,  0 = default cheat report, 1 = connected player stats",
	func = function(name, param)
		local privs = minetest.get_player_privs(name).privs;
		if not cheat.moderators[name] and not privs then return end
		
		if param == "" then 
			minetest.chat_send_player(name,"use: crep type, types: 0(default) cheat report, 1 connected player stats (".. version ..")");
		end
		
		param = tonumber(param) or 0;
		
		if param == 0 then -- show cheat report
			local text = "";
			for ip,v in pairs(anticheatdb) do
				if v and v.name and v.msg then
					text = text .. "ip " .. ip .. " ".. v.msg .. "\n";
				end
			end
			if text ~= "" then
				local form = "size [6,7] textarea[0,0;6.5,8.5;creport;CHEAT REPORT;".. text.."]"
				minetest.show_formspec(name, "anticheatreport", form)
			end
		elseif param == 1 then -- show cheat stats
			local text = "";
			local players = minetest.get_connected_players();
			for _,player in pairs(players) do
				local pname = player:get_player_name();
				local ip = tostring(minetest.get_player_ip(pname));
				
				
				text = text .. "\nname " .. pname .. ", digxp " .. math.floor(1000*cheat.players[pname].stats.digxp)/1000 ..
				", deltadigxp(2min) " .. math.floor(1000*cheat.players[pname].stats.deltadig)/1000 .. ", maxdeltadigxp " .. math.floor(1000*cheat.players[pname].stats.maxdeltadig)/1000; -- .. " ".. string.gsub(dump(cheat.players[pname].stats), "\n", " ");
				if anticheatdb[ip] then text = text .. "	(DETECTED) ip ".. ip .. ", name " .. anticheatdb[ip].name end
			end
			if text ~= "" then
				local form = "size [10,8] textarea[0,0;10.5,9.;creport;CHEAT STATISTICS;".. text.."]"
				minetest.show_formspec(name, "anticheatreport", form)
			end
		end
		-- suspects info
		local players = minetest.get_connected_players();
		for _,player in pairs(players) do
			local pname = player:get_player_name();
			if cheat.players[pname].count>0 then
				minetest.chat_send_player(name, "name " .. pname .. ", cheat pos " .. minetest.pos_to_string(cheat.players[pname].cheatpos) .. " last clear pos " .. minetest.pos_to_string(cheat.players[pname].lastpos) .. " cheat type " .. cheat.players[pname].cheattype .. " cheatcount " .. cheat.players[pname].count );
			end
		end

	end
})

minetest.register_chatcommand("cdebug", { -- toggle cdebug= display of stats on/off for this player
	privs = {
		interact = true
	},
	func = function(name, param)
		local privs = minetest.get_player_privs(name).privs;
		if not cheat.moderators[name] and not privs then return end
		
		if cheat.debuglist[name] == nil then cheat.debuglist[name] = true else cheat.debuglist[name] = nil end;
		
		minetest.chat_send_player(name,"#anticheat: " .. version);
		if cheat.debuglist[name]==true then 
			minetest.chat_send_player(name,"#anticheat: display of debug messages is ON");
		else
			minetest.chat_send_player(name,"#anticheat: display of debug messages is OFF");
		end
	end
})


-------------------------------------------------------------------------
-- GOVERNONR custom action for features mod code improvements
-- minetest routines to implement the mod features
-- Copyright 2020-2023 mckaygerhard CC-BY-SA-NC 4.0
-------------------------------------------------------------------------

-- cleanup after the player leaves
minetest.register_on_leaveplayer(function(player)
	if is_player(player) then
		track_player_clear(player)
	end
end)


-- list of nodes to enable damage if noclip or to check if player its diggin too fast
local node_list_check = {}

-- TODO move this to a comma separted list in config, and adde the check below in node check for fly
if minetest.get_modpath("default") then
	table.insert(node_list_check, "default:stone")
	table.insert(node_list_check, "default:stone_with_iron")
	table.insert(node_list_check, "default:stone_with_copper")
	table.insert(node_list_check, "default:stone_with_coal")
	table.insert(node_list_check, "default:stone_with_gold")
	table.insert(node_list_check, "default:stone_with_mese")
	table.insert(node_list_check, "default:stone_with_diamond")
end

local function interval_fn()
	if not governing.modbeowulf then
		for _, player in ipairs(minetest.get_connected_players()) do
			track_player(player)
		end
	end
	if not governing.modanticheat then
		for _, nodename in ipairs(node_list_check) do
			set_check_can_dig(nodename);
		end
	end

	minetest.after(2, interval_fn)
end

-- TODO: beowulf was pretty unneficient on large player servers.. cos only its made each 1 second and again each x inside function
if governing.is_50 then
	minetest.register_on_mods_loaded(interval_fn )
else
	minetest.after(0.1, interval_fn)
end

-- damaged if a player its making noclip, no matter if admin is doing
for _, nodename in ipairs(node_list_check) do
	minetest.override_item(nodename, {
		damage_per_second = 1
	})

end


