diff --git a/.github/workflows/check-release.yml b/.github/workflows/check-release.yml index 95fced4..5708489 100644 --- a/.github/workflows/check-release.yml +++ b/.github/workflows/check-release.yml @@ -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: + diff --git a/.github/workflows/luacheck.yml b/.github/workflows/luacheck.yml new file mode 100644 index 0000000..6d20257 --- /dev/null +++ b/.github/workflows/luacheck.yml @@ -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 ./ + diff --git a/.github/workflows/mineunit.yml b/.github/workflows/mineunit.yml new file mode 100644 index 0000000..b1423aa --- /dev/null +++ b/.github/workflows/mineunit.yml @@ -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 + diff --git a/.luacheckrc b/.luacheckrc index 8143a8c..2486b97 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -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" } } } diff --git a/LICENSE b/LICENSE index 5a8e332..541c140 100644 --- a/LICENSE +++ b/LICENSE @@ -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 +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 - 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 + +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. +-- diff --git a/README.md b/README.md index 6f9f21e..62879f6 100644 --- a/README.md +++ b/README.md @@ -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 `
-`/unwatch` (get back to your initial position) +All the commands can be modified in settings, here they are listed with their default names.
+ +`/watch ` silently attach to player
+`/unwatch` (get back to your initial position)
+`/watchme [, +`/smn` reject an invitation
+`/smy` accept an invitation
+ +## Settings + +All settings can be set in minetest.conf or accessed via mod with the global field of same name.
+[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.
+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
+`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 +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. + diff --git a/init.lua b/init.lua index bbbf654..4a6f3b1 100644 --- a/init.lua +++ b/init.lua @@ -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 (|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 = "", - 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 = '', + 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 = '[,[ ]' .. ']', + 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) + diff --git a/mod.conf b/mod.conf index 707ce83..6c7b4be 100644 --- a/mod.conf +++ b/mod.conf @@ -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. """ diff --git a/settingtypes.txt b/settingtypes.txt new file mode 100644 index 0000000..9da0aad --- /dev/null +++ b/settingtypes.txt @@ -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 + diff --git a/spec/fixtures/beerchat.lua b/spec/fixtures/beerchat.lua new file mode 100644 index 0000000..0efedf9 --- /dev/null +++ b/spec/fixtures/beerchat.lua @@ -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 } + diff --git a/spec/fixtures/mineunit_extensions.lua b/spec/fixtures/mineunit_extensions.lua new file mode 100644 index 0000000..3c61112 --- /dev/null +++ b/spec/fixtures/mineunit_extensions.lua @@ -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 + diff --git a/spec/fixtures/player_api.lua b/spec/fixtures/player_api.lua new file mode 100644 index 0000000..7904ef8 --- /dev/null +++ b/spec/fixtures/player_api.lua @@ -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 = {} } + diff --git a/spec/init_spec.lua b/spec/init_spec.lua new file mode 100644 index 0000000..a7476fa --- /dev/null +++ b/spec/init_spec.lua @@ -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) + diff --git a/spec/mineunit.conf b/spec/mineunit.conf new file mode 100644 index 0000000..dc41b29 --- /dev/null +++ b/spec/mineunit.conf @@ -0,0 +1,7 @@ +fixture_paths = { + "spec/fixtures", +} +exclude = { +} +verbose = 3 +