570 lines
18 KiB
Lua
570 lines
18 KiB
Lua
-- NOTE: in the output texts, the names are always in double quotes because some players have
|
|
-- names that can be confusing without the quotes.
|
|
-- IDEA: technically it would be possible to chain observe. Would have to climb the parent tree
|
|
-- making sure there is nothing circular happening. Including checking all the children.
|
|
-- A lot can go wrong with that, so it has been left out for now.
|
|
-- Another complication to this is that there are many combinations of client<->server
|
|
-- software versions to consider.
|
|
-- IDEA: might be nice to have a /send_home (<player>|all) command for invitie to detach
|
|
-- invited guests again.
|
|
-- Currently player can force detachment by logging off.
|
|
spectator_mode = {
|
|
version = 20220214,
|
|
command_accept = minetest.settings:get('spectator_mode.command_accept') or 'smy',
|
|
command_deny = minetest.settings:get('spectator_mode.command_deny') or 'smn',
|
|
command_detach = minetest.settings:get('spectator_mode.command_detach') or 'unwatch',
|
|
command_invite = minetest.settings:get('spectator_mode.command_invite') or 'watchme',
|
|
command_attach = minetest.settings:get('spectator_mode.command_attach') or 'watch',
|
|
invitation_timeout = tonumber(minetest.settings:get(
|
|
'spectator_mode.invitation_timeout') or 1 * 60),
|
|
|
|
keep_all_observers_alive = minetest.settings:get_bool(
|
|
'spectator_mode.keep_all_observers_alive', false),
|
|
|
|
priv_invite = minetest.settings:get('spectator_mode.priv_invite') or 'interact',
|
|
priv_watch = minetest.settings:get('spectator_mode.priv_watch') or 'watch',
|
|
}
|
|
local sm = spectator_mode
|
|
do
|
|
local temp = minetest.settings:get('spectator_mode.extra_observe_privs') or ''
|
|
sm.extra_observe_privs, sm.extra_observe_privs_moderator = {}, nil
|
|
for _, priv in ipairs(temp:split(',')) do
|
|
sm.extra_observe_privs[priv] = true
|
|
end
|
|
temp = minetest.settings:get('spectator_mode.extra_observe_privs_moderator') or ''
|
|
if temp == '' then
|
|
-- if no extra settings for moderators are set, then the table for observers
|
|
-- is linked and both use the same table reference.
|
|
sm.extra_observe_privs_moderator = sm.extra_observe_privs
|
|
-- if you prefer to keep the lists separate, uncomment next line
|
|
--sm.extra_observe_privs_moderator = table.copy(sm.extra_observe_privs)
|
|
else
|
|
sm.extra_observe_privs_moderator = {}
|
|
for _, priv in ipairs(temp:split(',')) do
|
|
sm.extra_observe_privs_moderator[priv] = true
|
|
end
|
|
end
|
|
end
|
|
if minetest.global_exists('beerchat') then
|
|
if 'function' == type(beerchat.has_player_muted_player) then
|
|
sm.beerchat_has_muted = beerchat.has_player_muted_player
|
|
end
|
|
end
|
|
|
|
-- cache of saved states indexed by player name
|
|
-- original_state['watcher'] = state
|
|
local original_state = {}
|
|
|
|
-- hash-table of pending invites
|
|
-- invites['invited_player'] = 'inviting_player'
|
|
local invites = {}
|
|
|
|
-- hash-table for accepted invites.
|
|
-- Used to determine whether watched gets notifiction when watcher detaches
|
|
-- invited['invited_player'] = 'inviting_player'
|
|
local invited = {}
|
|
|
|
|
|
-- register privs after all mods have loaded as user may want to reuse other privs
|
|
minetest.register_on_mods_loaded(function()
|
|
if not minetest.registered_privileges[sm.priv_watch] then
|
|
minetest.register_privilege(sm.priv_watch, {
|
|
description = 'Player can watch other players.',
|
|
give_to_singleplayer = false,
|
|
give_to_admin = true,
|
|
})
|
|
end
|
|
|
|
if not minetest.registered_privileges[sm.priv_invite] then
|
|
minetest.register_privilege(sm.priv_invite, {
|
|
description = 'Player can invite other players to watch them.',
|
|
give_to_singleplayer = false,
|
|
give_to_admin = true,
|
|
})
|
|
end
|
|
end)
|
|
|
|
|
|
-- TODO: consider making this public
|
|
local function original_state_get(player)
|
|
if not player or not player:is_player() then return end
|
|
|
|
-- check cache
|
|
local state = original_state[player:get_player_name()]
|
|
if state then return state end
|
|
|
|
-- fallback to player's meta
|
|
return minetest.deserialize(player:get_meta():get_string('spectator_mode:state'))
|
|
end -- original_state_get
|
|
|
|
|
|
local function original_state_set(player, state)
|
|
if not player or not player:is_player() then return end
|
|
|
|
-- save to cache
|
|
original_state[player:get_player_name()] = state
|
|
|
|
-- backup to player's meta
|
|
player:get_meta():set_string('spectator_mode:state', minetest.serialize(state))
|
|
end -- original_state_set
|
|
|
|
|
|
local function original_state_delete(player)
|
|
if not player or not player:is_player() then return end
|
|
-- remove from cache
|
|
original_state[player:get_player_name()] = nil
|
|
-- remove backup
|
|
player:get_meta():set_string('spectator_mode:state', '')
|
|
end -- original_state_delete
|
|
|
|
|
|
-- keep moderators alive when they used '/watch' command
|
|
-- overridable as servers may want to change this
|
|
function spectator_mode.keep_alive(name_watcher)
|
|
local watcher = minetest.get_player_by_name(name_watcher)
|
|
if not watcher then return end -- logged off
|
|
|
|
-- still attached?
|
|
if not original_state[name_watcher] then return end
|
|
|
|
-- has enough air? (avoid showing bubbles when not needed)
|
|
if 8 > watcher:get_breath() then
|
|
watcher:set_breath(9)
|
|
end
|
|
minetest.after(5, sm.keep_alive, name_watcher)
|
|
end -- keep_alive
|
|
|
|
|
|
-- can be overriden to manipulate new_hud_flags
|
|
-- flags are the current hud_flags of player
|
|
-- luacheck: no unused args
|
|
function spectator_mode.turn_off_hud_hook(player, flags, new_hud_flags)
|
|
new_hud_flags.breathbar = flags.breathbar
|
|
new_hud_flags.healthbar = flags.healthbar
|
|
end -- turn_off_hud_hook
|
|
-- luacheck: unused args
|
|
|
|
|
|
-- this doesn't hide /postool hud, hunger bar and similar
|
|
local function turn_off_hud_flags(player)
|
|
local flags = player:hud_get_flags()
|
|
local new_hud_flags = {}
|
|
for flag in pairs(flags) do
|
|
new_hud_flags[flag] = false
|
|
end
|
|
sm.turn_off_hud_hook(player, flags, new_hud_flags)
|
|
player:hud_set_flags(new_hud_flags)
|
|
end -- turn_off_hud_flags
|
|
|
|
|
|
-- called by the detach command '/unwatch'
|
|
-- called on logout if player is attached at that time
|
|
-- called before attaching to another player
|
|
local function detach(name_watcher)
|
|
-- nothing to do
|
|
if not player_api.player_attached[name_watcher] then return end
|
|
|
|
local watcher = minetest.get_player_by_name(name_watcher)
|
|
if not watcher then return end -- shouldn't ever happen
|
|
|
|
watcher:set_detach()
|
|
player_api.player_attached[name_watcher] = false
|
|
watcher:set_eye_offset()
|
|
|
|
local state = original_state_get(watcher)
|
|
-- nothing else to do
|
|
if not state then return end
|
|
|
|
-- NOTE: older versions of MT/MC may not have this
|
|
watcher:set_nametag_attributes({
|
|
color = state.nametag.color,
|
|
bgcolor = state.nametag.bgcolor
|
|
})
|
|
watcher:hud_set_flags(state.hud_flags)
|
|
watcher:set_properties({
|
|
visual_size = state.visual_size,
|
|
makes_footstep_sound = state.makes_footstep_sound,
|
|
collisionbox = state.collisionbox,
|
|
show_on_minimap = state.show_on_minimap or true, -- or minetest default for players
|
|
})
|
|
|
|
-- restore privs
|
|
local privs = minetest.get_player_privs(name_watcher)
|
|
privs.interact = state.priv_interact
|
|
local privs_extra = invited[name_watcher] and sm.extra_observe_privs
|
|
or sm.extra_observe_privs_moderator
|
|
|
|
for key, _ in pairs(privs_extra) do
|
|
privs[key] = state.privs_extra[key]
|
|
end
|
|
minetest.set_player_privs(name_watcher, privs)
|
|
|
|
-- set_pos seems to be very unreliable
|
|
-- this workaround helps though
|
|
minetest.after(0.1, function()
|
|
watcher:set_pos(state.pos)
|
|
-- delete state only after actually moved.
|
|
-- this helps re-attach after log-off/server crash
|
|
original_state_delete(watcher)
|
|
end)
|
|
|
|
-- if watcher was invited, notify invitee that watcher has detached
|
|
if invited[name_watcher] then
|
|
invited[name_watcher] = nil
|
|
minetest.chat_send_player(state.target,
|
|
'"' .. name_watcher .. '" has stopped looking over your shoulder.')
|
|
|
|
end
|
|
minetest.log('action', '[spectator_mode] "' .. name_watcher
|
|
.. '" detached from "' .. state.target .. '"')
|
|
|
|
end -- detach
|
|
|
|
|
|
-- both players are online and all checks have been done when this
|
|
-- method is called
|
|
local function attach(name_watcher, name_target)
|
|
|
|
-- detach from cart, horse, bike etc.
|
|
detach(name_watcher)
|
|
|
|
local watcher = minetest.get_player_by_name(name_watcher)
|
|
local privs_watcher = minetest.get_player_privs(name_watcher)
|
|
-- back up some attributes
|
|
local properties = watcher:get_properties()
|
|
local state = {
|
|
collisionbox = properties.collisionbox,
|
|
hud_flags = watcher:hud_get_flags(),
|
|
makes_footstep_sound = properties.makes_footstep_sound,
|
|
nametag = watcher:get_nametag_attributes(),
|
|
show_on_minimap = properties.show_on_minimap,
|
|
pos = watcher:get_pos(),
|
|
priv_interact = privs_watcher.interact,
|
|
privs_extra = {},
|
|
target = name_target,
|
|
visual_size = properties.visual_size,
|
|
}
|
|
local privs_extra
|
|
if invites[name_watcher] then
|
|
privs_extra = sm.extra_observe_privs
|
|
else
|
|
-- wasn't invited -> '/watch' used by moderator
|
|
privs_extra = sm.extra_observe_privs_moderator
|
|
end
|
|
|
|
for key, _ in pairs(privs_extra) do
|
|
state.privs_extra[key] = privs_watcher[key]
|
|
privs_watcher[key] = true
|
|
end
|
|
original_state_set(watcher, state)
|
|
|
|
-- set some attributes
|
|
turn_off_hud_flags(watcher)
|
|
watcher:set_properties({
|
|
visual_size = { x = 0, y = 0 },
|
|
makes_footstep_sound = false,
|
|
collisionbox = { 0 }, -- TODO: is this the proper/best way?
|
|
show_on_minimap = false,
|
|
})
|
|
watcher:set_nametag_attributes({ color = { a = 0 }, bgcolor = { a = 0 } })
|
|
local eye_pos = vector.new(0, -5, -20)
|
|
watcher:set_eye_offset(eye_pos)
|
|
-- make sure watcher can't interact
|
|
privs_watcher.interact = nil
|
|
minetest.set_player_privs(name_watcher, privs_watcher)
|
|
-- and attach
|
|
player_api.player_attached[name_watcher] = true
|
|
local target = minetest.get_player_by_name(name_target)
|
|
watcher:set_attach(target, '', eye_pos)
|
|
minetest.log('action', '[spectator_mode] "' .. name_watcher
|
|
.. '" attached to "' .. name_target .. '"')
|
|
|
|
if sm.keep_all_observers_alive or (not invites[name_watcher]) then
|
|
-- server keeps all observers alive
|
|
-- or moderator used '/watch' to sneak up without invite
|
|
minetest.after(3, sm.keep_alive, name_watcher)
|
|
end
|
|
end -- attach
|
|
|
|
|
|
-- called by '/watch' command
|
|
local function watch(name_watcher, name_target)
|
|
if original_state[name_watcher] then
|
|
return true, 'You are currently watching "'
|
|
.. original_state[name_watcher].target
|
|
.. '". Say /' .. sm.command_detach .. ' first.'
|
|
|
|
end
|
|
if name_watcher == name_target then
|
|
return true, 'You may not watch yourself.'
|
|
end
|
|
|
|
local target = minetest.get_player_by_name(name_target)
|
|
if not target then
|
|
return true, 'Invalid target name "' .. name_target .. '"'
|
|
end
|
|
|
|
-- avoid infinite loops
|
|
if original_state[name_target] then
|
|
return true, '"' .. name_target .. '" is watching "'
|
|
.. original_state[name_target].target .. '". You may not watch a watcher.'
|
|
end
|
|
|
|
attach(name_watcher, name_target)
|
|
return true, 'Watching "' .. name_target .. '" at '
|
|
.. minetest.pos_to_string(vector.round(target:get_pos()))
|
|
|
|
end -- watch
|
|
|
|
|
|
local function invite_timed_out(name_watcher)
|
|
-- did the watcher already accept/decline?
|
|
if not invites[name_watcher] then return end
|
|
|
|
minetest.chat_send_player(invites[name_watcher],
|
|
'Invitation to "' .. name_watcher .. '" timed-out.')
|
|
|
|
minetest.chat_send_player(name_watcher,
|
|
'Invitation from "' .. invites[name_watcher] .. '" timed-out.')
|
|
|
|
invites[name_watcher] = nil
|
|
end -- invite_timed_out
|
|
|
|
|
|
-- called by '/watchme' command
|
|
local function watchme(name_target, param)
|
|
if original_state[name_target] then
|
|
return true, 'You are watching "' .. original_state[name_target].target
|
|
.. '", no chain watching is allowed.'
|
|
end
|
|
|
|
if '' == param then
|
|
return true, 'Please provide at least one player name.'
|
|
end
|
|
|
|
local messages = {}
|
|
local count_invites = 0
|
|
local invitation_timeout_string = tostring(sm.invitation_timeout)
|
|
local invitation_postfix = '" has invited you to observe them. '
|
|
.. 'Accept with /' .. sm.command_accept
|
|
.. ', deny with /' .. sm.command_deny .. '.\n'
|
|
.. 'The invite expires in ' .. invitation_timeout_string .. ' seconds.'
|
|
|
|
-- checks whether watcher may be invited by target and returns error message if not
|
|
-- if permitted, invites watcher and returns success message
|
|
local function invite(name_watcher)
|
|
if name_watcher == name_target then
|
|
return 'You may not watch yourself.'
|
|
end
|
|
|
|
if original_state[name_watcher] then
|
|
return '"' .. name_watcher .. '" is busy watching another player.'
|
|
end
|
|
|
|
if invites[name_watcher] then
|
|
return '"' .. name_watcher .. '" has a pending invite, try again later.'
|
|
end
|
|
|
|
if not minetest.get_player_by_name(name_watcher) then
|
|
return '"' .. name_watcher .. '" is not online.'
|
|
end
|
|
|
|
if not sm.is_permited_to_invite(name_target, name_watcher) then
|
|
return 'You may not invite "' .. name_watcher .. '".'
|
|
end
|
|
|
|
count_invites = count_invites + 1
|
|
invites[name_watcher] = name_target
|
|
minetest.after(sm.invitation_timeout, invite_timed_out, name_watcher)
|
|
-- notify invited
|
|
minetest.chat_send_player(name_watcher, '"' .. name_target .. invitation_postfix)
|
|
|
|
-- notify invitee
|
|
return 'You have invited "' .. name_watcher .. '".'
|
|
end -- invite()
|
|
|
|
for name_watcher in string.gmatch(param, '[^%s,]+') do
|
|
table.insert(messages, invite(name_watcher))
|
|
end
|
|
-- notify invitee
|
|
local text = table.concat(messages, '\n')
|
|
if 0 < count_invites then
|
|
text = text .. '\nThe invitations expire in '
|
|
.. invitation_timeout_string .. ' seconds.'
|
|
end
|
|
return true, text
|
|
end -- watchme
|
|
|
|
|
|
-- this function only checks privs etc. Mechanics are already checked in watchme()
|
|
-- other mods can override and extend these checks
|
|
function spectator_mode.is_permited_to_invite(name_target, name_watcher)
|
|
if minetest.get_player_privs(name_target)[sm.priv_watch] then
|
|
return true
|
|
end
|
|
|
|
if not minetest.get_player_privs(name_target)[sm.priv_invite] then
|
|
return false
|
|
end
|
|
|
|
-- check for beerchat mute/ignore
|
|
if sm.beerchat_has_muted and sm.beerchat_has_muted(name_watcher, name_target) then
|
|
return false
|
|
end
|
|
|
|
return true
|
|
end -- is_permited_to_invite
|
|
|
|
|
|
-- called by the accept command '/smy'
|
|
local function accept_invite(name_watcher)
|
|
local name_target = invites[name_watcher]
|
|
if not name_target then
|
|
return true, 'There is no invite for you. Maybe it timed-out.'
|
|
end
|
|
|
|
attach(name_watcher, name_target)
|
|
invites[name_watcher] = nil
|
|
invited[name_watcher] = name_target
|
|
minetest.chat_send_player(name_target,
|
|
'"' .. name_watcher .. '" is now attached to you.')
|
|
|
|
return true, 'OK, you have been attached to "' .. name_target .. '". To disable type /'
|
|
.. sm.command_detach
|
|
|
|
end -- accept_invite
|
|
|
|
|
|
-- called by the deny command '/smn'
|
|
local function decline_invite(name_watcher)
|
|
if not invites[name_watcher] then
|
|
return true, 'There is no invite for you. Maybe it timed-out.'
|
|
end
|
|
|
|
minetest.chat_send_player(invites[name_watcher],
|
|
'"' .. name_watcher .. '" declined the invite.')
|
|
|
|
invites[name_watcher] = nil
|
|
return true, 'OK, declined invite.'
|
|
end -- decline_invite
|
|
|
|
|
|
local function unwatch(name_watcher)
|
|
-- nothing to do
|
|
if not player_api.player_attached[name_watcher] then
|
|
return true, 'You are not observing anybody.'
|
|
end
|
|
|
|
detach(name_watcher)
|
|
return true -- no message as that has been sent by detach()
|
|
end -- unwatch
|
|
|
|
|
|
local function on_joinplayer(watcher)
|
|
local state = original_state_get(watcher)
|
|
if not state then return end
|
|
|
|
-- attempt to move to original state after log-off
|
|
-- during attach or server crash
|
|
local name_watcher = watcher:get_player_name()
|
|
original_state[name_watcher] = state
|
|
player_api.player_attached[name_watcher] = true
|
|
detach(name_watcher)
|
|
end -- on_joinplayer
|
|
|
|
|
|
local function on_leaveplayer(watcher)
|
|
local name_watcher = watcher:get_player_name()
|
|
if invites[name_watcher] then
|
|
-- invitation exists for leaving player
|
|
minetest.chat_send_player(invites[name_watcher],
|
|
'Invitation to "' .. name_watcher .. '" invalidated because of logout.')
|
|
|
|
invites[name_watcher] = nil
|
|
end
|
|
-- detach before leaving
|
|
detach(name_watcher)
|
|
-- detach any that are watching this user
|
|
local attached = {}
|
|
for name, state in pairs(original_state) do
|
|
if name_watcher == state.target then
|
|
table.insert(attached, name)
|
|
end
|
|
end
|
|
-- we use separate loop to avoid editing a
|
|
-- hash while it's being looped
|
|
for _, name in ipairs(attached) do
|
|
detach(name)
|
|
end
|
|
end -- on_leaveplayer
|
|
|
|
|
|
-- different servers may want different behaviour, they can
|
|
-- override this function
|
|
function spectator_mode.on_respawnplayer(watcher)
|
|
-- * Called when player is to be respawned
|
|
-- * Called _before_ repositioning of player occurs
|
|
-- * return true in func to disable regular player placement
|
|
local state = original_state_get(watcher)
|
|
if not state then return end
|
|
|
|
local name_target = state.target
|
|
local name_watcher = watcher:get_player_name()
|
|
player_api.player_attached[name_watcher] = true
|
|
-- detach destroys invited entry, we need to restore that
|
|
if invited[name_watcher] then
|
|
detach(name_watcher)
|
|
-- mark as invited so players get info in chat on detach.
|
|
invited[name_watcher] = name_target
|
|
else
|
|
-- was a moderator using '/watch' -> conceal the spy.
|
|
detach(name_watcher)
|
|
end
|
|
minetest.after(.4, attach, name_watcher, name_target)
|
|
return true
|
|
end -- on_respawnplayer
|
|
|
|
|
|
minetest.register_chatcommand(sm.command_attach, {
|
|
params = '<target name>',
|
|
description = 'Watch a given player',
|
|
privs = { [sm.priv_watch] = true },
|
|
func = watch,
|
|
})
|
|
|
|
|
|
minetest.register_chatcommand(sm.command_detach, {
|
|
description = 'Unwatch a player',
|
|
privs = { },
|
|
func = unwatch,
|
|
})
|
|
|
|
|
|
minetest.register_chatcommand(sm.command_invite, {
|
|
description = 'Invite player(s) to watch you',
|
|
params = '<player name>[,<player2 name>[ <playerN name>]' .. ']',
|
|
privs = { [sm.priv_invite] = true },
|
|
func = watchme,
|
|
})
|
|
|
|
|
|
minetest.register_chatcommand(sm.command_accept, {
|
|
description = 'Accept an invitation to watch another player',
|
|
params = '',
|
|
privs = { },
|
|
func = accept_invite,
|
|
})
|
|
|
|
|
|
minetest.register_chatcommand(sm.command_deny, {
|
|
description = 'Deny an invitation to watch another player',
|
|
params = '',
|
|
privs = { },
|
|
func = decline_invite,
|
|
})
|
|
|
|
|
|
minetest.register_on_joinplayer(on_joinplayer)
|
|
minetest.register_on_leaveplayer(on_leaveplayer)
|
|
minetest.register_on_respawnplayer(spectator_mode.on_respawnplayer)
|
|
|