Extend mod to also support invites (#15)

MAJOR MOD CHANGE

As before, moderators can silently observe any player that is not observing another player. (configurable)

New: Players can invite other players to observe them. Invitations can be accepted or denied. (configurable privileges, defaults to interact)

All the chat-commands are configurable.
Temporary privileges can be given to observers, same set for sneak-observers and invited ones or separate set.
master
Luke aka SwissalpS 2022-03-08 17:03:18 +01:00 committed by GitHub
parent 487307fc8f
commit 1b031ebd4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1170 additions and 106 deletions

View File

@ -8,4 +8,5 @@ jobs:
- name: lint
uses: Roang-zero1/factorio-mod-luacheck@master
with:
luacheckrc_url: https://raw.githubusercontent.com/minetest-mods/spectator_mode/master/.luacheckrc
luacheckrc_url:

14
.github/workflows/luacheck.yml vendored Normal file
View File

@ -0,0 +1,14 @@
name: luacheck
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: apt
run: sudo apt-get install -y luarocks
- name: luacheck install
run: luarocks install --local luacheck
- name: luacheck run
run: $HOME/.luarocks/bin/luacheck ./

12
.github/workflows/mineunit.yml vendored Normal file
View File

@ -0,0 +1,12 @@
name: mineunit
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: mt-mods/mineunit-actions@v0.3

View File

@ -1,12 +1,20 @@
unused_args = false
allow_defined_top = true
read_globals = {
"minetest",
"vector",
-- Exclude regression tests / unit tests
exclude_files = {
"**/spec/**",
}
globals = {
"default",
"player_api",
player_api = { fields = { "player_attached" } },
"spectator_mode",
}
read_globals = {
-- Stdlib
string = { fields = { "split" } },
table = { fields = { "copy", "insert" } },
-- Minetest
"minetest",
vector = { fields = { "copy", "new", "round" } },
beerchat = { fields = { "has_player_muted_player" } }
}

60
LICENSE
View File

@ -1,14 +1,56 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
--
-- MIT License for spectator_mode code.
-- Applies to everything that is not covered with additional copyright notes.
--
-- Notes:
-- See notes below MIT license, notes in source code and copyright notes for Minetest.
--
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
MIT License
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
Copyright (c) 2015 Jean-Patrick Guerrero <jeanpatrick.guerrero@gmail.com>
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
0. You just DO WHAT THE FUCK YOU WANT TO.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--
-- Minetest LGPLv2.1 license contents
--
Minetest
Copyright (C) 2010-2018 celeron55, Perttu Ahola <celeron55@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation; either version 2.1 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 Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
--
-- Additional licenses apply, see source files and subdirectories for more information.
--

View File

@ -1,4 +1,8 @@
# Spectator Mode
[![luacheck](https://github.com/minetest-mods/spectator_mode/workflows/luacheck/badge.svg)](https://github.com/minetest-mods/spectator_mode/actions)
[![mineunit](https://github.com/minetest-mods/spectator_mode/workflows/mineunit/badge.svg)](https://github.com/minetest-mods/spectator_mode/actions)
[![License](https://img.shields.io/badge/License-MIT%20and%20CC--BY--SA--3.0-green.svg)](LICENSE)
[![Minetest](https://img.shields.io/badge/Minetest-5.0+-blue.svg)](https://www.minetest.net)
A mod for Minetest allowing you to watch other players in their 3rd person view.
You're invisible and undetectable for the players when you're in this mode.
@ -6,6 +10,8 @@ You're invisible and undetectable for the players when you're in this mode.
Can be useful for admins or moderators in their task of monitoring.
Requires the privilege `watch`.
Normal players can also invite others to observe them.
## Dependencies
- `player_api` (included in [`minetest_game`](https://github.com/minetest/minetest_game))
@ -17,5 +23,46 @@ This mod requires MT 5.0.0 and above.
## Commands
`/watch <player name>`<br>
`/unwatch` (get back to your initial position)
All the commands can be modified in settings, here they are listed with their default names.<br>
`/watch <player name>` silently attach to player<br>
`/unwatch` (get back to your initial position)<br>
`/watchme <player name>[,<player2 name] ... playerN name]]` invite player(s) to observe caller.<br>
`/smn` reject an invitation<br>
`/smy` accept an invitation<br>
## Settings
All settings can be set in minetest.conf or accessed via mod with the global field of same name.<br>
[See settingtypes.txt](settingtypes.txt)
## Privileges
Both privileges are registered if no other mod has already done so.
## Compatibility
Before sending invites, beerchat's player meta entry is checked to make sure muted players can't invite.<br>
Other mods can override `spectator_mode.is_permited_to_invite(name_target, name_watcher)` to add own
conditions of when who can invite whom.
Moderators are kept breathing when observing via '/watch' command. Other mods can override this to
add more functionality: `spectator_mode.keep_alive(name_watcher)`.
`spectator_mode.on_respawnplayer(watcher)` can be overidden to adjust what happens when an attached player
dies and respawns. Without change, the observer is detached for a split second then re-attached.
While attaching a player, his hud flags are mostly turned off. Other mods can override the behaviour
with their own implementation of<br>
`function spectator_mode.turn_off_hud_hook(player, flags, new_hud_flags)`
- **player** The PlayerObjectRef of player that is to be attached.
- **flags** The player's HUD-flags prior to attaching.
- **new_hud_flags** The table that can be manipulated and will be set as new flags.
## Copyright
Original mod DWTFYW Copyright (C) 2015 Jean-Patrick Guerrero <jeanpatrick.guerrero@gmail.com>
Since 20220217 MIT and CC-BY-SA-3.0[see LICENSE](LICENSE)
The MIT applies to all code in this project that is not otherwise protected. [see LICENSE](LICENSE)
The CC-BY-SA-3.0 license applies to textures and any other content in this project which is not source code.

619
init.lua
View File

@ -1,116 +1,567 @@
local original_pos = {}
-- 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),
minetest.register_privilege("watch", {
description = "Player can watch other players",
give_to_singleplayer = false,
give_to_admin = true,
})
keep_all_observers_alive = minetest.settings:get_bool(
'spectator_mode.keep_all_observers_alive', false),
local function toggle_hud_flags(player, bool)
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] = bool
new_hud_flags[flag] = false
end
sm.turn_off_hud_hook(player, flags, new_hud_flags)
player:hud_set_flags(new_hud_flags)
end
end -- turn_off_hud_flags
local function unwatching(name)
local watcher = minetest.get_player_by_name(name)
local privs = minetest.get_player_privs(name)
if watcher and default.player_attached[name] == true then
watcher:set_detach()
player_api.player_attached[name] = false
watcher:set_eye_offset(vector.new(), vector.new())
watcher:set_nametag_attributes({color = {a = 255, r = 255, g = 255, b = 255}})
-- 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
toggle_hud_flags(watcher, true)
local watcher = minetest.get_player_by_name(name_watcher)
if not watcher then return end -- shouldn't ever happen
watcher:set_properties({
visual_size = {x = 1, y = 1},
makes_footstep_sound = true,
collisionbox = {-0.3, 0.0, -0.3, 0.3, 1.7, 0.3}
})
watcher:set_detach()
player_api.player_attached[name_watcher] = false
watcher:set_eye_offset()
if not privs.interact and privs.watch == true then
privs.interact = true
minetest.set_player_privs(name, privs)
end
local state = original_state_get(watcher)
-- nothing else to do
if not state then return end
local pos = original_pos[name]
if pos then
-- set_pos seems to be very unreliable
-- this workaround helps though
minetest.after(0.1, function()
watcher:set_pos(pos)
end)
original_pos[name] = nil
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,
})
-- 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
end
minetest.set_player_privs(name_watcher, privs)
minetest.register_chatcommand("watch", {
params = "<to_name>",
description = "Watch a given player",
privs = {watch = true},
func = function(name_watcher, name_target)
-- 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(),
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?
})
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 true, "You may not watch yourself"
return 'You may not watch yourself.'
end
local target = minetest.get_player_by_name(name_target)
if not target then
return true, "Unknown target player name"
if original_state[name_watcher] then
return '"' .. name_watcher .. '" is busy watching another player.'
end
-- avoid infinite loops
if original_pos[name_target] then
return true, name_target .. " is already watching a player."
if invites[name_watcher] then
return '"' .. name_watcher .. '" has a pending invite, try again later.'
end
local watcher = minetest.get_player_by_name(name_watcher)
local privs_watcher = minetest.get_player_privs(name_watcher)
if player_api.player_attached[name_watcher] == true then
unwatching(name_watcher)
if not minetest.get_player_by_name(name_watcher) then
return '"' .. name_watcher .. '" is not online.'
end
original_pos[name_watcher] = watcher:get_pos()
player_api.player_attached[name_watcher] = true
watcher:set_attach(target, "", vector.new(0, -5, -20), vector.new())
watcher:set_eye_offset(vector.new(0, -5, -20), vector.new())
watcher:set_nametag_attributes({color = {a = 0}})
if not sm.is_permited_to_invite(name_target, name_watcher) then
return 'You may not invite "' .. name_watcher .. '".'
end
toggle_hud_flags(watcher, true)
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)
watcher:set_properties({
visual_size = {x = 0, y = 0},
makes_footstep_sound = false,
collisionbox = {0}
})
privs_watcher.interact = nil
minetest.set_player_privs(name_watcher, privs_watcher)
return true, 'Watching "' .. name_target .. '" at '
.. minetest.pos_to_string(vector.round(target:get_pos()))
-- 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("unwatch", {
description = "Unwatch a player",
privs = {watch=true},
func = function(name, param)
unwatching(name)
end
minetest.register_chatcommand(sm.command_detach, {
description = 'Unwatch a player',
privs = { },
func = unwatch,
})
minetest.register_on_leaveplayer(function(player)
local name = player:get_player_name()
unwatching(name)
end)
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)

View File

@ -1,6 +1,9 @@
name = spectator_mode
depends = default, player_api
optional_depends = beerchat
description = """
A mod for Minetest allowing you to watch other players in their 3rd person view.
You're invisible and undetectable for the players when you're in this mode.
A mod for Minetest allowing players to watch other players in their 3rd person view.
Observers are invisible and can be kept alive.
Privileged players can observe others without invites. They can give away their presence
by dropping items or death creating bones and visible dead player.
"""

36
settingtypes.txt Normal file
View File

@ -0,0 +1,36 @@
# After an invite has successfully been sent, the watcher needs to accept with this command.
spectator_mode.command_accept (Chatcommand to accept an invitation) string smy
# After an invite has successfully been sent, the watcher may decline it with this command.
spectator_mode.command_deny (Chatcommand to deny an invitation) string smn
# To stop observing another player, issue this command
spectator_mode.command_detach (Chatcommand to stop observing a player) string unwatch
# Additional privs granted to observers. e.g. noclip,never_hungry
spectator_mode.extra_observe_privs (Extra privs for observers) string
# Additional privs granted to observers that used '/watch' command. e.g. jail,kick,teleport
# If left empty will use spectator_mode.extra_observe_privs (same table reference).
spectator_mode.extra_observe_privs_moderator (Extra privs for observing moderators) string
# To invite another player to observe player that issued this command
spectator_mode.command_invite (Chatcommand to invite other player) string watchme
# To start observing another player, issue this command
spectator_mode.command_attach (Chatcommand to start observing a player) string watch
# Invitations invalidate after this many seconds if they haven't been accepted or denied
spectator_mode.invitation_timeout (Duration invites are valid for in seconds) int 60
# Not only moderators are kept oxygenated, but all observers when this is set to true.
# For auto-feeding the spectator_mode.keep_alive() function needs to be overridden by
# another mod.
spectator_mode.keep_all_observers_alive (Keep all observers alive) bool false
# The priv needed to send observation invites.
spectator_mode.priv_invite (Player priv to invite others to observe) string interact
# The priv needed to silently observe any player that isn't currently watching another one
spectator_mode.priv_watch (Moderator priv to watch any player) string watch

17
spec/fixtures/beerchat.lua vendored Normal file
View File

@ -0,0 +1,17 @@
-- emulating the only part of [beerchat] that this mod uses
-- https://github.com/minetest-beerchat/beerchat/blob/69400d640c5f6972ab3c69b955b012aecba53ad5/common.lua#L56
mineunit:set_modpath("beerchat", "../beerchat")
_G.beerchat = { has_player_muted_player = function(name, other_name)
local player = minetest.get_player_by_name(name)
-- check jic method is used incorrectly
if not player then
return true
end
local key = "beerchat:muted:" .. other_name
local meta = player:get_meta()
return "true" == meta:get_string(key)
end }

115
spec/fixtures/mineunit_extensions.lua vendored Normal file
View File

@ -0,0 +1,115 @@
-- methods that mineunit has not yet implemented or will never implement but
-- are needed by the unit tests.
-- not needed with core 5.5.0 (possibly earlier)
function vector.copy(v) return { x = v.x or 0, y = v.y or 0, z = v.z or 0 } end
-- not needed with core 5.5.0 (possibly earlier)
function vector.zero() return { x = 0, y = 0, z = 0 } end
function Player:hud_get_flags()
return self._hud_flags or { hotbar = true, healthbar = true, crosshair = true,
wielditem = true, breathbar = true, minimap = false, minimap_radar = false }
end
function Player:hud_set_flags(new_flags)
if not self._hud_flags then self._hud_flags = self:hud_get_flags() end
for flag, value in pairs(new_flags) do if nil ~= self._hud_flags[flag] then self._hud_flags[flag] = not not value end end
end
function ObjectRef:get_nametag_attributes()
if not self._nametag_attributes then self._nametag_attributes = {
text = self._nametag_text or '',
color = self._nametag_color or { a = 255, r = 255, g = 255, b = 255 },
bgcolor = self._nametag_bgcolor or { a = 0, r = 0, g = 0, b = 0 },
}
end
return self._nametag_attributes
end
function Player:set_eye_offset(firstperson, thirdperson)
self._eye_offset_first =
firstperson and vector.copy(firstperson) or vector.zero()
thirdperson = thirdperson and vector.copy(thirdperson) or vector.zero()
thirdperson.x = math.max(-10, math.min(10, thirdperson.x))
thirdperson.y = math.max(-10, math.min(15, thirdperson.y))
thirdperson.z = math.max(-5, math.min(5, thirdperson.z))
self._eye_offset_third = thirdperson
end
function Player:get_breath() return self._breath or 10 end
function Player:set_breath(value) self._breath = tonumber(value) or self._breath end
function ObjectRef:set_nametag_attributes(new_attributes)
if not self._nametag_attributes then self:get_nametag_attributes() end
for key, value in pairs(new_attributes) do
if nil ~= self._nametag_attributes[key] then
if 'name' == key then
self._nametag_attributes.name = tostring(value)
else
for subkey, subvalue in pairs(new_attributes[key]) do
if nil ~= self._nametag_attributes[key][subkey] then
self._nametag_attributes[key][subkey] = tonumber(subvalue)
end
end
end
end
end
end
function ObjectRef:set_pos(value)
self._pos = vector.copy(value)
for _, child in ipairs(self:get_children()) do
child:set_pos(vector.add(self._pos, child._attach.position))
end
end
function ObjectRef:set_attach(parent, bone, position, rotation, forced_visible)
if not parent then return end
if self._attach and self._attach.parent == parent then
mineunit:info('Attempt to attach to parent that object is already attached to.')
return
end
-- detach if attached
self:set_detach()
local obj = parent
while true do
if not obj._attach then break end
if obj._attach.parent == self then
mineunit:warning('Mod bug: Attempted to attach object to an object that '
.. 'is directly or indirectly attached to the first object. -> '
.. 'circular attachment chain.')
return
end
obj = obj._attach.parent
end
if 'table' ~= type(parent._children) then parent._children = {} end
table.insert(parent._children, self)
self._attach = {
parent = parent,
bone = bone or '',
position = position or vector.zero(),
rotation = rotation or vector.zero(),
forced_visible = not not forced_visible,
}
self._pitch = self._attach.position.x
self._roll = self._attach.position.z
self._yaw = self._attach.position.y
self:set_pos(vector.add(parent:get_pos(), self._attach.position))
-- TODO: bones depending on object type
end
function ObjectRef:get_attach()
return self._attach
end
function ObjectRef:get_children()
return self._children or {}
end
function ObjectRef:set_detach()
if not self._attach then return end
local new_children = {}
for _, child in ipairs(self._attach.parent._children) do
if child ~= self then table.insert(new_children, child) end
end
self._attach.parent._children = new_children
self._attach = nil
end

6
spec/fixtures/player_api.lua vendored Normal file
View File

@ -0,0 +1,6 @@
-- emulating the only part of [player_api] that this mod uses
mineunit:set_modpath("player_api", "../player_api")
_G.player_api = { player_attached = {} }

305
spec/init_spec.lua Normal file
View File

@ -0,0 +1,305 @@
-- main unit testing file that mineunit picks up
-- https://github.com/S-S-X/mineunit
require("mineunit")
mineunit("core")
mineunit("player")
mineunit("server")
mineunit('common/after')
-- mimic player_api.player_attached
fixture('player_api')
-- add some not yet included functions
fixture('mineunit_extensions')
-- mimic beerchat.has_player_muted_player
fixture('beerchat')
local function pd1(m) print(dump(m)) end
local function pd(...) for _, m in ipairs({...}) do pd1(m) end end
-- override chat_send_player to inspect what was sent
local chatlog = {}
local core_chat_send_player = core.chat_send_player
function core.chat_send_player(to_name, message)
table.insert(chatlog, { to = to_name, message = message })
return core_chat_send_player(to_name, message)
end
local function reset_chatlog() chatlog = {} end
describe("Mod initialization", function()
it("Wont crash", function()
sourcefile("init")
end)
end)
describe('Watching:', function()
-- create some players
local players = {
SX = Player("SX", { interact = 1 }),
boss = Player("boss", { interact = 1, watch = 1 }),
dude1 = Player("dude1", { interact = 1, }),
dude2 = Player("dude2", { interact = 1, }),
dude3 = Player("dude3", { interact = false, }),
}
local start_positions = {}
local boss = players.boss
local dude1 = players.dude1
local dude2 = players.dude2
local dude3 = players.dude3
setup(function()
-- make sure the privs are registered
mineunit:mods_loaded()
-- log on all players and move them to unique positions
local i, pos = 1
for name, player in pairs(players) do
mineunit:execute_on_joinplayer(player)
pos = vector.new(10 * i, 20 * i, 30 * i)
start_positions[name] = pos
player:set_pos(pos)
i = i + 1
end
end)
teardown(function()
mineunit:info('shutting down server')
for _, player in pairs(players) do
mineunit:execute_on_leaveplayer(player)
end
mineunit:execute_globalstep(100)
end)
it("boss attaches to dude1", function()
reset_chatlog()
boss:send_chat_message("/watch dude1")
assert.equals(1, #chatlog, 'unexpected amount of messages, '
.. 'was dude1 notified by accident')
assert.equals('boss', chatlog[1].to)
assert.equals(1, chatlog[1].message:find('^Watching "dude1" at %('))
local pos = boss:get_pos()
assert.equals(start_positions.dude1.x, pos.x)
assert.equals(start_positions.dude1.y - 5, pos.y)
assert.equals(start_positions.dude1.z - 20, pos.z)
end)
it('boss returns to start position and nobody is notified about it', function()
-- let's make sure boss is still attached, jic we change previous test
assert.is_false(vector.equals(start_positions.boss, boss:get_pos()),
'boss is still at starting position: unit setup error')
reset_chatlog()
boss:send_chat_message('/unwatch')
mineunit:execute_globalstep(1)
assert.equals(0, #chatlog, 'there was an error message sent to ?')
assert.is_true(vector.equals(start_positions.boss, boss:get_pos()),
'boss did not move back to starting position')
end)
it('boss tries to unwatch when not watching', function()
reset_chatlog()
boss:send_chat_message('/unwatch')
mineunit:execute_globalstep(1)
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.is_not_nil(chatlog[1].message:find('not observing'),
'unexpected chat response')
end)
it('player receives message when issuing /unwatch while not attached', function()
reset_chatlog()
dude1:send_chat_message('/unwatch')
mineunit:execute_globalstep(1)
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.is_not_nil(chatlog[1].message:find('not observing'),
'unexpected chat response')
end)
it('invitations are sent and expire', function()
reset_chatlog()
-- we also test multiple invites with space separation
dude2:send_chat_message('/watchme dude1 SX')
assert.equals('dude1', chatlog[1].to, 'dude1 did not get invited')
assert.equals('SX', chatlog[2].to, 'SX did not get invited')
assert.equals('dude2', chatlog[3].to, 'dude2 did not get message')
local message = chatlog[3].message
assert.is_true(message:find('"dude1"') and message:find('"SX"') and true,
'response message does not contain invities')
assert.is_not_nil(message:find('60 seconds'),
'response message does not contain expire info')
reset_chatlog()
mineunit:execute_globalstep(60)
assert.equals(4, #chatlog, 'unexpected chatlog count')
reset_chatlog()
players.SX:send_chat_message('/smy')
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('SX', chatlog[1].to, 'message was not sent to SX but ' .. chatlog[1].to)
message = chatlog[1].message
assert.is_not_nil(message:find('timed%-out%.$'),
'time out message does not end with "timed-out."')
local pos = players.SX:get_pos()
assert.is_true(vector.equals(start_positions.SX, pos),
'Invitation did not expire, SX was moved.')
end)
it('watching by normal player not possible', function()
local pos = dude2:get_pos()
reset_chatlog()
dude2:send_chat_message('/watch dude1')
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('dude2', chatlog[1].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('to run this command'),
'unexpected error message')
assert.is_true(vector.equals(pos, dude2:get_pos()))
end)
it('inviting is not possible when not having invite priv', function()
local last_priv = spectator_mode.priv_invite
-- set needed priv to avoid using dude3, this allows us to test
-- minetest.conf settings
spectator_mode.priv_invite = 'testPrivThatDoesNotExist'
reset_chatlog()
-- we also test comma list
dude2:send_chat_message('/watchme dude1,SX')
assert.equals(1, #chatlog, 'unexpected chatlog count')
local message = chatlog[1].message
assert.is_not_nil(message:find('"dude1"'), 'did not parse player list correctly')
assert.is_not_nil(message:find('"SX"'), 'did not parse player list correctly')
assert.is_not_nil(message:find('^You may not invite'), 'did not revoke inivting')
assert.is_nil(message:find('60 seconds'), 'time out message was wrongfully added')
-- restore priv setting
spectator_mode.priv_invite = last_priv
end)
it('player can not invite himself', function()
reset_chatlog()
dude1:send_chat_message('/watchme dude1')
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('dude1', chatlog[1].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('may not watch yourself'),
'unexpected chat response')
end)
it('moderator can not invite himself', function()
reset_chatlog()
boss:send_chat_message('/watchme boss')
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('boss', chatlog[1].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('may not watch yourself'),
'unexpected chat response')
end)
it('moderator can not attach to an observing player and gets name of observed player', function()
dude1:send_chat_message('/watchme dude2')
dude2:send_chat_message('/smy')
mineunit:execute_globalstep(1)
reset_chatlog()
boss:send_chat_message('/watch dude2')
mineunit:execute_globalstep(1)
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('boss', chatlog[1].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('^"dude2" is watching "dude1"'),
'unexpected chat response')
end)
it('player can not invite an observing player', function()
reset_chatlog()
players.SX:send_chat_message('/watchme dude2')
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('SX', chatlog[1].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('^"dude2".*watching another player%.$'),
'unexpected chat response')
end)
-- TODO: check if this can't be exploited to make circular attaching
it('player can invite an observed player', function()
reset_chatlog()
players.SX:send_chat_message('/watchme dude1')
assert.equals(2, #chatlog, 'unexpected chatlog count')
assert.equals('dude1', chatlog[1].to, 'unexpected recipient.')
assert.equals('SX', chatlog[2].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('^"SX".* seconds%.$'),
'unexpected chat response')
end)
it('player can deny an invite', function()
reset_chatlog()
dude1:send_chat_message('/smn')
assert.equals(2, #chatlog, 'unexpected chatlog count')
assert.equals('SX', chatlog[1].to, 'unexpected recipient.')
assert.equals('dude1', chatlog[2].to, 'unexpected recipient.')
end)
it('player can detach and returns to original position', function()
reset_chatlog()
dude2:send_chat_message('/unwatch')
mineunit:execute_globalstep(1)
assert.is_true(vector.equals(start_positions.dude2, dude2:get_pos()),
'dude2 did not move back to starting position')
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('dude1', chatlog[1].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('^"dude2" has stopped loo'),
'unexpected chat response')
end)
it('can not invite a player with pending invitation', function()
dude1:send_chat_message('/watchme dude2')
reset_chatlog()
players.SX:send_chat_message('/watchme dude2')
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('SX', chatlog[1].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('^"dude2" has a pen'),
'unexpected chat response')
end)
it('boss can attach to an unwatched player with pending invitation', function()
reset_chatlog()
boss:send_chat_message('/watch dude2')
mineunit:execute_globalstep(1)
assert.is_true(vector.equals(boss:get_pos(), vector.add(
dude2:get_pos(), vector.new(0, -5, -20))), 'boss not with dude2')
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('boss', chatlog[1].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('^Watching "dude2"'),
'unexpected chat response')
end)
it('can accept invitation after a moderator also attached', function()
reset_chatlog()
dude2:send_chat_message('/smy')
mineunit:execute_globalstep(1)
assert.equals(2, #chatlog, 'unexpected chatlog count')
assert.equals('dude1', chatlog[1].to, 'unexpected recipient.')
assert.equals('dude2', chatlog[2].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('^"dude2" is now attached'),
'unexpected chat response')
assert.is_not_nil(chatlog[2].message:find('^OK, you have been atta'),
'unexpected chat response')
end)
it('dude2 is detached when dude1 logs off', function()
reset_chatlog()
mineunit:execute_on_leaveplayer(dude1)
mineunit:execute_globalstep(1)
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('dude1', chatlog[1].to, 'unexpected recipient.')
assert.is_not_nil(chatlog[1].message:find('^"dude2" has stopped loo'),
'unexpected chat response')
end)
it('dude3 ignores dude1 and can not be invited by dude1', function()
reset_chatlog()
dude3:get_meta():set_string('beerchat:muted:dude1', 'true')
dude1:send_chat_message('/watchme dude3')
assert.equals(1, #chatlog, 'unexpected chatlog count')
assert.equals('dude1', chatlog[1].to, 'unexpected recipient.')
assert.equals(chatlog[1].message, 'You may not invite "dude3".')
end)
it('boss is detached when he logs on after logging off while attached', function()
reset_chatlog()
assert.is_true(vector.equals(boss:get_pos(), vector.add(
dude2:get_pos(), vector.new(0, -5, -20))), 'boss not with dude2')
mineunit:execute_on_leaveplayer(boss)
mineunit:execute_globalstep(1)
assert.equals(0, #chatlog, 'unexpected chatlog count')
mineunit:execute_on_joinplayer(boss)
mineunit:execute_globalstep(1)
assert.is_false(vector.equals(boss:get_pos(),
vector.add(dude2:get_pos(), vector.new(0, -5, -20))),
'boss with dude2')
end)
end)

7
spec/mineunit.conf Normal file
View File

@ -0,0 +1,7 @@
fixture_paths = {
"spec/fixtures",
}
exclude = {
}
verbose = 3