-
Notifications
You must be signed in to change notification settings - Fork 8
/
init.lua
570 lines (462 loc) · 17.6 KB
/
init.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
-- NOTE: in the output texts, the names are always in double quotes because some players have
-- names that can be confusing without the quotes.
-- IDEA: technically it would be possible to chain observe. Would have to climb the parent tree
-- making sure there is nothing circular happening. Including checking all the children.
-- A lot can go wrong with that, so it has been left out for now.
-- Another complication to this is that there are many combinations of client<->server
-- software versions to consider.
-- IDEA: might be nice to have a /send_home (<player>|all) command for invitie to detach
-- invited guests again.
-- Currently player can force detachment by logging off.
spectator_mode = {
version = 20220214,
command_accept = minetest.settings:get('spectator_mode.command_accept') or 'smy',
command_deny = minetest.settings:get('spectator_mode.command_deny') or 'smn',
command_detach = minetest.settings:get('spectator_mode.command_detach') or 'unwatch',
command_invite = minetest.settings:get('spectator_mode.command_invite') or 'watchme',
command_attach = minetest.settings:get('spectator_mode.command_attach') or 'watch',
invitation_timeout = tonumber(minetest.settings:get(
'spectator_mode.invitation_timeout') or 1 * 60),
keep_all_observers_alive = minetest.settings:get_bool(
'spectator_mode.keep_all_observers_alive', false),
priv_invite = minetest.settings:get('spectator_mode.priv_invite') or 'interact',
priv_watch = minetest.settings:get('spectator_mode.priv_watch') or 'watch',
}
local sm = spectator_mode
do
local temp = minetest.settings:get('spectator_mode.extra_observe_privs') or ''
sm.extra_observe_privs, sm.extra_observe_privs_moderator = {}, nil
for _, priv in ipairs(temp:split(',')) do
sm.extra_observe_privs[priv] = true
end
temp = minetest.settings:get('spectator_mode.extra_observe_privs_moderator') or ''
if temp == '' then
-- if no extra settings for moderators are set, then the table for observers
-- is linked and both use the same table reference.
sm.extra_observe_privs_moderator = sm.extra_observe_privs
-- if you prefer to keep the lists separate, uncomment next line
--sm.extra_observe_privs_moderator = table.copy(sm.extra_observe_privs)
else
sm.extra_observe_privs_moderator = {}
for _, priv in ipairs(temp:split(',')) do
sm.extra_observe_privs_moderator[priv] = true
end
end
end
if minetest.global_exists('beerchat') then
if 'function' == type(beerchat.has_player_muted_player) then
sm.beerchat_has_muted = beerchat.has_player_muted_player
end
end
-- cache of saved states indexed by player name
-- original_state['watcher'] = state
local original_state = {}
-- hash-table of pending invites
-- invites['invited_player'] = 'inviting_player'
local invites = {}
-- hash-table for accepted invites.
-- Used to determine whether watched gets notifiction when watcher detaches
-- invited['invited_player'] = 'inviting_player'
local invited = {}
-- register privs after all mods have loaded as user may want to reuse other privs
minetest.register_on_mods_loaded(function()
if not minetest.registered_privileges[sm.priv_watch] then
minetest.register_privilege(sm.priv_watch, {
description = 'Player can watch other players.',
give_to_singleplayer = false,
give_to_admin = true,
})
end
if not minetest.registered_privileges[sm.priv_invite] then
minetest.register_privilege(sm.priv_invite, {
description = 'Player can invite other players to watch them.',
give_to_singleplayer = false,
give_to_admin = true,
})
end
end)
-- TODO: consider making this public
local function original_state_get(player)
if not player or not player:is_player() then return end
-- check cache
local state = original_state[player:get_player_name()]
if state then return state end
-- fallback to player's meta
return minetest.deserialize(player:get_meta():get_string('spectator_mode:state'))
end -- original_state_get
local function original_state_set(player, state)
if not player or not player:is_player() then return end
-- save to cache
original_state[player:get_player_name()] = state
-- backup to player's meta
player:get_meta():set_string('spectator_mode:state', minetest.serialize(state))
end -- original_state_set
local function original_state_delete(player)
if not player or not player:is_player() then return end
-- remove from cache
original_state[player:get_player_name()] = nil
-- remove backup
player:get_meta():set_string('spectator_mode:state', '')
end -- original_state_delete
-- keep moderators alive when they used '/watch' command
-- overridable as servers may want to change this
function spectator_mode.keep_alive(name_watcher)
local watcher = minetest.get_player_by_name(name_watcher)
if not watcher then return end -- logged off
-- still attached?
if not original_state[name_watcher] then return end
-- has enough air? (avoid showing bubbles when not needed)
if 8 > watcher:get_breath() then
watcher:set_breath(9)
end
minetest.after(5, sm.keep_alive, name_watcher)
end -- keep_alive
-- can be overriden to manipulate new_hud_flags
-- flags are the current hud_flags of player
-- luacheck: no unused args
function spectator_mode.turn_off_hud_hook(player, flags, new_hud_flags)
new_hud_flags.breathbar = flags.breathbar
new_hud_flags.healthbar = flags.healthbar
end -- turn_off_hud_hook
-- luacheck: unused args
-- this doesn't hide /postool hud, hunger bar and similar
local function turn_off_hud_flags(player)
local flags = player:hud_get_flags()
local new_hud_flags = {}
for flag in pairs(flags) do
new_hud_flags[flag] = false
end
sm.turn_off_hud_hook(player, flags, new_hud_flags)
player:hud_set_flags(new_hud_flags)
end -- turn_off_hud_flags
-- called by the detach command '/unwatch'
-- called on logout if player is attached at that time
-- called before attaching to another player
local function detach(name_watcher)
-- nothing to do
if not player_api.player_attached[name_watcher] then return end
local watcher = minetest.get_player_by_name(name_watcher)
if not watcher then return end -- shouldn't ever happen
watcher:set_detach()
player_api.player_attached[name_watcher] = false
watcher:set_eye_offset()
local state = original_state_get(watcher)
-- nothing else to do
if not state then return end
-- NOTE: older versions of MT/MC may not have this
watcher:set_nametag_attributes({
color = state.nametag.color,
bgcolor = state.nametag.bgcolor
})
watcher:hud_set_flags(state.hud_flags)
watcher:set_properties({
visual_size = state.visual_size,
makes_footstep_sound = state.makes_footstep_sound,
collisionbox = state.collisionbox,
show_on_minimap = state.show_on_minimap or true, -- or minetest default for players
})
-- restore privs
local privs = minetest.get_player_privs(name_watcher)
privs.interact = state.priv_interact
local privs_extra = invited[name_watcher] and sm.extra_observe_privs
or sm.extra_observe_privs_moderator
for key, _ in pairs(privs_extra) do
privs[key] = state.privs_extra[key]
end
minetest.set_player_privs(name_watcher, privs)
-- set_pos seems to be very unreliable
-- this workaround helps though
minetest.after(0.1, function()
watcher:set_pos(state.pos)
-- delete state only after actually moved.
-- this helps re-attach after log-off/server crash
original_state_delete(watcher)
end)
-- if watcher was invited, notify invitee that watcher has detached
if invited[name_watcher] then
invited[name_watcher] = nil
minetest.chat_send_player(state.target,
'"' .. name_watcher .. '" has stopped looking over your shoulder.')
end
minetest.log('action', '[spectator_mode] "' .. name_watcher
.. '" detached from "' .. state.target .. '"')
end -- detach
-- both players are online and all checks have been done when this
-- method is called
local function attach(name_watcher, name_target)
-- detach from cart, horse, bike etc.
detach(name_watcher)
local watcher = minetest.get_player_by_name(name_watcher)
local privs_watcher = minetest.get_player_privs(name_watcher)
-- back up some attributes
local properties = watcher:get_properties()
local state = {
collisionbox = properties.collisionbox,
hud_flags = watcher:hud_get_flags(),
makes_footstep_sound = properties.makes_footstep_sound,
nametag = watcher:get_nametag_attributes(),
show_on_minimap = properties.show_on_minimap,
pos = watcher:get_pos(),
priv_interact = privs_watcher.interact,
privs_extra = {},
target = name_target,
visual_size = properties.visual_size,
}
local privs_extra
if invites[name_watcher] then
privs_extra = sm.extra_observe_privs
else
-- wasn't invited -> '/watch' used by moderator
privs_extra = sm.extra_observe_privs_moderator
end
for key, _ in pairs(privs_extra) do
state.privs_extra[key] = privs_watcher[key]
privs_watcher[key] = true
end
original_state_set(watcher, state)
-- set some attributes
turn_off_hud_flags(watcher)
watcher:set_properties({
visual_size = { x = 0, y = 0 },
makes_footstep_sound = false,
collisionbox = { 0 }, -- TODO: is this the proper/best way?
show_on_minimap = false,
})
watcher:set_nametag_attributes({ color = { a = 0 }, bgcolor = { a = 0 } })
local eye_pos = vector.new(0, -5, -20)
watcher:set_eye_offset(eye_pos)
-- make sure watcher can't interact
privs_watcher.interact = nil
minetest.set_player_privs(name_watcher, privs_watcher)
-- and attach
player_api.player_attached[name_watcher] = true
local target = minetest.get_player_by_name(name_target)
watcher:set_attach(target, '', eye_pos)
minetest.log('action', '[spectator_mode] "' .. name_watcher
.. '" attached to "' .. name_target .. '"')
if sm.keep_all_observers_alive or (not invites[name_watcher]) then
-- server keeps all observers alive
-- or moderator used '/watch' to sneak up without invite
minetest.after(3, sm.keep_alive, name_watcher)
end
end -- attach
-- called by '/watch' command
local function watch(name_watcher, name_target)
if original_state[name_watcher] then
return true, 'You are currently watching "'
.. original_state[name_watcher].target
.. '". Say /' .. sm.command_detach .. ' first.'
end
if name_watcher == name_target then
return true, 'You may not watch yourself.'
end
local target = minetest.get_player_by_name(name_target)
if not target then
return true, 'Invalid target name "' .. name_target .. '"'
end
-- avoid infinite loops
if original_state[name_target] then
return true, '"' .. name_target .. '" is watching "'
.. original_state[name_target].target .. '". You may not watch a watcher.'
end
attach(name_watcher, name_target)
return true, 'Watching "' .. name_target .. '" at '
.. minetest.pos_to_string(vector.round(target:get_pos()))
end -- watch
local function invite_timed_out(name_watcher)
-- did the watcher already accept/decline?
if not invites[name_watcher] then return end
minetest.chat_send_player(invites[name_watcher],
'Invitation to "' .. name_watcher .. '" timed-out.')
minetest.chat_send_player(name_watcher,
'Invitation from "' .. invites[name_watcher] .. '" timed-out.')
invites[name_watcher] = nil
end -- invite_timed_out
-- called by '/watchme' command
local function watchme(name_target, param)
if original_state[name_target] then
return true, 'You are watching "' .. original_state[name_target].target
.. '", no chain watching is allowed.'
end
if '' == param then
return true, 'Please provide at least one player name.'
end
local messages = {}
local count_invites = 0
local invitation_timeout_string = tostring(sm.invitation_timeout)
local invitation_postfix = '" has invited you to observe them. '
.. 'Accept with /' .. sm.command_accept
.. ', deny with /' .. sm.command_deny .. '.\n'
.. 'The invite expires in ' .. invitation_timeout_string .. ' seconds.'
-- checks whether watcher may be invited by target and returns error message if not
-- if permitted, invites watcher and returns success message
local function invite(name_watcher)
if name_watcher == name_target then
return 'You may not watch yourself.'
end
if original_state[name_watcher] then
return '"' .. name_watcher .. '" is busy watching another player.'
end
if invites[name_watcher] then
return '"' .. name_watcher .. '" has a pending invite, try again later.'
end
if not minetest.get_player_by_name(name_watcher) then
return '"' .. name_watcher .. '" is not online.'
end
if not sm.is_permited_to_invite(name_target, name_watcher) then
return 'You may not invite "' .. name_watcher .. '".'
end
count_invites = count_invites + 1
invites[name_watcher] = name_target
minetest.after(sm.invitation_timeout, invite_timed_out, name_watcher)
-- notify invited
minetest.chat_send_player(name_watcher, '"' .. name_target .. invitation_postfix)
-- notify invitee
return 'You have invited "' .. name_watcher .. '".'
end -- invite()
for name_watcher in string.gmatch(param, '[^%s,]+') do
table.insert(messages, invite(name_watcher))
end
-- notify invitee
local text = table.concat(messages, '\n')
if 0 < count_invites then
text = text .. '\nThe invitations expire in '
.. invitation_timeout_string .. ' seconds.'
end
return true, text
end -- watchme
-- this function only checks privs etc. Mechanics are already checked in watchme()
-- other mods can override and extend these checks
function spectator_mode.is_permited_to_invite(name_target, name_watcher)
if minetest.get_player_privs(name_target)[sm.priv_watch] then
return true
end
if not minetest.get_player_privs(name_target)[sm.priv_invite] then
return false
end
-- check for beerchat mute/ignore
if sm.beerchat_has_muted and sm.beerchat_has_muted(name_watcher, name_target) then
return false
end
return true
end -- is_permited_to_invite
-- called by the accept command '/smy'
local function accept_invite(name_watcher)
local name_target = invites[name_watcher]
if not name_target then
return true, 'There is no invite for you. Maybe it timed-out.'
end
attach(name_watcher, name_target)
invites[name_watcher] = nil
invited[name_watcher] = name_target
minetest.chat_send_player(name_target,
'"' .. name_watcher .. '" is now attached to you.')
return true, 'OK, you have been attached to "' .. name_target .. '". To disable type /'
.. sm.command_detach
end -- accept_invite
-- called by the deny command '/smn'
local function decline_invite(name_watcher)
if not invites[name_watcher] then
return true, 'There is no invite for you. Maybe it timed-out.'
end
minetest.chat_send_player(invites[name_watcher],
'"' .. name_watcher .. '" declined the invite.')
invites[name_watcher] = nil
return true, 'OK, declined invite.'
end -- decline_invite
local function unwatch(name_watcher)
-- nothing to do
if not player_api.player_attached[name_watcher] then
return true, 'You are not observing anybody.'
end
detach(name_watcher)
return true -- no message as that has been sent by detach()
end -- unwatch
local function on_joinplayer(watcher)
local state = original_state_get(watcher)
if not state then return end
-- attempt to move to original state after log-off
-- during attach or server crash
local name_watcher = watcher:get_player_name()
original_state[name_watcher] = state
player_api.player_attached[name_watcher] = true
detach(name_watcher)
end -- on_joinplayer
local function on_leaveplayer(watcher)
local name_watcher = watcher:get_player_name()
if invites[name_watcher] then
-- invitation exists for leaving player
minetest.chat_send_player(invites[name_watcher],
'Invitation to "' .. name_watcher .. '" invalidated because of logout.')
invites[name_watcher] = nil
end
-- detach before leaving
detach(name_watcher)
-- detach any that are watching this user
local attached = {}
for name, state in pairs(original_state) do
if name_watcher == state.target then
table.insert(attached, name)
end
end
-- we use separate loop to avoid editing a
-- hash while it's being looped
for _, name in ipairs(attached) do
detach(name)
end
end -- on_leaveplayer
-- different servers may want different behaviour, they can
-- override this function
function spectator_mode.on_respawnplayer(watcher)
-- * Called when player is to be respawned
-- * Called _before_ repositioning of player occurs
-- * return true in func to disable regular player placement
local state = original_state_get(watcher)
if not state then return end
local name_target = state.target
local name_watcher = watcher:get_player_name()
player_api.player_attached[name_watcher] = true
-- detach destroys invited entry, we need to restore that
if invited[name_watcher] then
detach(name_watcher)
-- mark as invited so players get info in chat on detach.
invited[name_watcher] = name_target
else
-- was a moderator using '/watch' -> conceal the spy.
detach(name_watcher)
end
minetest.after(.4, attach, name_watcher, name_target)
return true
end -- on_respawnplayer
minetest.register_chatcommand(sm.command_attach, {
params = '<target name>',
description = 'Watch a given player',
privs = { [sm.priv_watch] = true },
func = watch,
})
minetest.register_chatcommand(sm.command_detach, {
description = 'Unwatch a player',
privs = { },
func = unwatch,
})
minetest.register_chatcommand(sm.command_invite, {
description = 'Invite player(s) to watch you',
params = '<player name>[,<player2 name>[ <playerN name>]' .. ']',
privs = { [sm.priv_invite] = true },
func = watchme,
})
minetest.register_chatcommand(sm.command_accept, {
description = 'Accept an invitation to watch another player',
params = '',
privs = { },
func = accept_invite,
})
minetest.register_chatcommand(sm.command_deny, {
description = 'Deny an invitation to watch another player',
params = '',
privs = { },
func = decline_invite,
})
minetest.register_on_joinplayer(on_joinplayer)
minetest.register_on_leaveplayer(on_leaveplayer)
minetest.register_on_respawnplayer(spectator_mode.on_respawnplayer)