features/server.lua
--- See documentation at https://github.com/Refactorio/RedMew/pull/469
local Token = require 'utils.token'
local Task = require 'utils.task'
local Global = require 'utils.global'
local Event = require 'utils.event'
local Timestamp = require 'utils.timestamp'
local Print = require('utils.print_override')
local ErrorLogging = require 'utils.error_logging'

local serialize = serpent.serialize
local concat = table.concat
local remove = table.remove
local tostring = tostring
local raw_print = Print.raw_print
local next = next
local type = type

local serialize_options = {sparse = true, compact = true}

local Public = {}

local server_time = {secs = nil, tick = 0}
local start_data = {server_id = nil, server_name = nil, start_time = nil}
ErrorLogging.server_time = server_time
local requests = {}

Global.register({server_time = server_time, start_data = start_data, requests = requests}, function(tbl)
    server_time = tbl.server_time
    ErrorLogging.server_time = server_time
    start_data = tbl.start_data
    requests = tbl.requests
end)

local discord_tag = '[DISCORD]'
local discord_raw_tag = '[DISCORD-RAW]'
local discord_bold_tag = '[DISCORD-BOLD]'
local discord_admin_tag = '[DISCORD-ADMIN]'
local discord_admin_raw_tag = '[DISCORD-ADMIN-RAW]'
local discord_embed_tag = '[DISCORD-EMBED]'
local discord_embed_raw_tag = '[DISCORD-EMBED-RAW]'
local discord_admin_embed_tag = '[DISCORD-ADMIN-EMBED]'
local discord_admin_embed_raw_tag = '[DISCORD-ADMIN-EMBED-RAW]'
local discord_named_tag = '[DISCORD-NAMED]'
local discord_named_raw_tag = '[DISCORD-NAMED-RAW]'
local discord_named_bold_tag = '[DISCORD-NAMED-BOLD]'
local discord_named_embed_tag = '[DISCORD-NAMED-EMBED]'
local discord_named_embed_raw_tag = '[DISCORD-NAMED-EMBED-RAW]'
local start_scenario_tag = '[START-SCENARIO]'
local start_game_tag ='[START-GAME]'
local ping_tag = '[PING]'
local data_set_tag = '[DATA-SET]'
local data_get_tag = '[DATA-GET]'
local data_get_all_tag = '[DATA-GET-ALL]'
local data_tracked_tag = '[DATA-TRACKED]'
local query_players_tag = '[QUERY-PLAYERS]'
local player_join_tag = '[PLAYER-JOIN]'
local player_leave_tag = '[PLAYER-LEAVE]'
local player_banned_tag = '[BAN]'

Public.raw_print = raw_print

local data_set_handlers = {}

local function assert_non_empty_string_and_no_spaces(str, argument_name)
    if type(str) ~= 'string' then
        error(argument_name .. ' must be a string', 3)
    end

    if #str == 0 then
        error(argument_name .. ' must not be an empty string', 3)
    end

    if str:match(' ') then
        error(argument_name .. " must not contain space ' ' character.", 3)
    end
end

--- The event id for the on_server_started event.
-- The event is raised whenever the server goes from the starting state to the running state.
-- It provides a good opportunity to request data from the web server.
-- Note that if the server is stopped then started again, this event will be raised again.
-- @usage
-- local Server = require 'features.server'
-- local Event = require 'utils.event'
--
-- Event.add(Server.events.on_server_started,
-- function()
--      Server.try_get_all_data('regulars', callback)
-- end)
Public.events = {on_server_started = Event.generate_event_name('on_server_started')}

--- Sends a message to the linked discord channel. The message is sanitized of markdown server side.
-- @param  message<string> message to send.
-- @usage
-- local Server = require 'features.server'
-- Server.to_discord('Hello from scenario script!')
function Public.to_discord(message)
    raw_print(discord_tag .. message)
end

--- Sends a message to the linked discord channel. The message is not sanitized of markdown.
-- @param  message<string> message to send.
function Public.to_discord_raw(message)
    raw_print(discord_raw_tag .. message)
end

--- Sends a message to the linked discord channel. The message is sanitized of markdown server side, then made bold.
-- @param  message<string> message to send.
function Public.to_discord_bold(message)
    raw_print(discord_bold_tag .. message)
end

--- Sends a message to the linked admin discord channel. The message is sanitized of markdown server side.
-- @param  message<string> message to send.
function Public.to_admin(message)
    raw_print(discord_admin_tag .. message)
end

--- Sends a message to the linked admin discord channel. The message is not sanitized of markdown.
-- @param  message<string> message to send.
function Public.to_admin_raw(message)
    raw_print(discord_admin_raw_tag .. message)
end

--- Sends an embed message to the linked discord channel. The message is sanitized of markdown server side.
-- @param  message<string> the content of the embed.
function Public.to_discord_embed(message)
    raw_print(discord_embed_tag .. message)
end

--- Sends an embed message to the linked discord channel. The message is not sanitized of markdown.
-- @param  message<string> the content of the embed.
function Public.to_discord_embed_raw(message)
    raw_print(discord_embed_raw_tag .. message)
end

--- Sends an embed message to the linked admin discord channel. The message is sanitized of markdown server side.
-- @param  message<string> the content of the embed.
function Public.to_admin_embed(message)
    raw_print(discord_admin_embed_tag .. message)
end

--- Sends a embed message to the linked admin discord channel. The message is not sanitized of markdown.
-- @param  message<string> the content of the embed.
function Public.to_admin_embed_raw(message)
    raw_print(discord_admin_embed_raw_tag .. message)
end

--- Sends a message to the named discord channel. The message is sanitized of markdown server side.
-- @param  message<string> message to send.
function Public.to_discord_named(channel_name, message)
    assert_non_empty_string_and_no_spaces(channel_name, 'channel_name')
    raw_print(concat({discord_named_tag, channel_name, ' ', message}))
end

--- Sends a message to the named discord channel. The message is not sanitized of markdown.
-- @param  message<string> message to send.
function Public.to_discord_named_raw(channel_name, message)
    assert_non_empty_string_and_no_spaces(channel_name, 'channel_name')
    raw_print(concat({discord_named_raw_tag, channel_name, ' ', message}))
end

--- Sends a message to the named discord channel. The message is sanitized of markdown server side, then made bold.
-- @param  message<string> message to send.
function Public.to_discord_named_bold(channel_name, message)
    assert_non_empty_string_and_no_spaces(channel_name, 'channel_name')
    raw_print(concat({discord_named_bold_tag, channel_name, ' ', message}))
end

--- Sends an embed message to the named discord channel. The message is sanitized of markdown server side.
-- @param  message<string> the content of the embed.
function Public.to_discord_named_embed(channel_name, message)
    assert_non_empty_string_and_no_spaces(channel_name, 'channel_name')
    raw_print(concat({discord_named_embed_tag, channel_name, ' ', message}))
end

--- Sends an embed message to the named discord channel. The message is not sanitized of markdown.
-- @param  message<string> the content of the embed.
function Public.to_discord_named_embed_raw(channel_name, message)
    assert_non_empty_string_and_no_spaces(channel_name, 'channel_name')
    raw_print(concat({discord_named_embed_raw_tag, channel_name, ' ', message}))
end

--- Tells the server that a ban has happend, this will overwrite any existing bans.
-- The purpose is that game.ban doesn't report the admin so we use this function to fill that in.
-- @param player_name<string> the player to ban.
-- @param admin_name<string> the admin that did the ban.
-- @param reason<string> the optional reason for the ban.
function Public.report_ban(player_name, admin_name, reason)
    assert_non_empty_string_and_no_spaces(player_name, 'player_name')
    assert_non_empty_string_and_no_spaces(admin_name, 'admin_name')
    raw_print(concat({player_banned_tag, ' ',  player_name, ' was banned by ', admin_name, '. Reason: ', reason}))
end

--- Stops and saves the factorio server and starts the named scenario.
-- @param  scenario_name<string> The name of the scenario as appears in the scenario table on http://redmew.com/admin
-- @usage
-- local Server = require 'features.server'
-- Server.start_scenario('my_scenario_name')
function Public.start_scenario(scenario_name)
    if type(scenario_name) ~= 'string' then
        game.print('start_scenario - scenario_name ' .. tostring(scenario_name) .. ' must be a string.')
        return
    end

    local message = start_scenario_tag .. scenario_name

    raw_print(message)
end

--- Stops the server and starts a new game.
-- @params start_game_data either string which is the scenario name or a table with the following fields
-- type:string<scenario|save> optional defaults to scenario.
-- name:string the name of the scenario or save to start.
-- mod_pack:string optional the name of the mod pack to use.
function Public.start_game(start_game_data)
    local data
    if type(start_game_data) == 'string' then
        data = {type = 'scenario', name = start_game_data}
    elseif type(start_game_data) == 'table' then
        data = start_game_data
    else
        error('start_game_data must be a string or table')
    end

    local json = helpers.table_to_json(data)
    raw_print(start_game_tag .. json)
end

local default_ping_token = Token.register(function(sent_tick)
    local now = game.tick
    local diff = now - sent_tick

    local message = concat({'Pong in ', diff, ' tick(s) ', 'sent tick: ', sent_tick, ' received tick: ', now})
    game.print(message)
end)

--- Pings the web server.
-- @param  func_token<token> The function that is called when the web server replies.
-- The function is passed the tick that the ping was sent.
function Public.ping(func_token)
    local message = concat({ping_tag, func_token or default_ping_token, ' ', game.tick})
    raw_print(message)
end

local function double_escape(str)
    -- Excessive escaping because the data is serialized twice.
    return str:gsub('\\', '\\\\\\\\'):gsub('"', '\\\\\\"'):gsub('\n', '\\\\n')
end

--- Sets the web server's persistent data storage. If you pass nil for the value removes the data.
-- Data set this will by synced in with other server if they choose to.
-- There can only be one key for each data_set.
-- @param  data_set<string>
-- @param  key<string>
-- @param  value<nil|boolean|number|string|table> Any type that is not a function. set to nil to remove the data.
-- @usage
-- local Server = require 'features.server'
-- Server.set_data('my data set', 'key 1', 123)
-- Server.set_data('my data set', 'key 2', 'abc')
-- Server.set_data('my data set', 'key 3', {'some', 'data', ['is_set'] = true})
--
-- Server.set_data('my data set', 'key 1', nil) -- this will remove 'key 1'
-- Server.set_data('my data set', 'key 2', 'def') -- this will change the value for 'key 2' to 'def'
function Public.set_data(data_set, key, value)
    if type(data_set) ~= 'string' then
        error('data_set must be a string', 2)
    end
    if type(key) ~= 'string' then
        error('key must be a string', 2)
    end

    data_set = double_escape(data_set)
    key = double_escape(key)

    local message
    local vt = type(value)
    if vt == 'nil' then
        message = concat({data_set_tag, '{data_set:"', data_set, '",key:"', key, '"}'})
    elseif vt == 'string' then
        value = double_escape(value)

        message = concat({data_set_tag, '{data_set:"', data_set, '",key:"', key, '",value:"\\"', value, '\\""}'})
    elseif vt == 'number' then
        message = concat({data_set_tag, '{data_set:"', data_set, '",key:"', key, '",value:"', value, '"}'})
    elseif vt == 'boolean' then
        message = concat({data_set_tag, '{data_set:"', data_set, '",key:"', key, '",value:"', tostring(value), '"}'})
    elseif vt == 'function' then
        error('value cannot be a function', 2)
    else -- table
        value = serialize(value, serialize_options)

        -- Less escaping than the string case as serpent provides one level of escaping.
        -- Need to escape single quotes as serpent uses double quotes for strings.
        value = value:gsub('\\', '\\\\'):gsub("'", "\\'")

        message = concat({data_set_tag, '{data_set:"', data_set, '",key:"', key, "\",value:'", value, "'}"})
    end

    raw_print(message)
end

local function validate_arguments(data_set, key, callback_token)
    if type(data_set) ~= 'string' then
        error('data_set must be a string', 3)
    end
    if type(key) ~= 'string' then
        error('key must be a string', 3)
    end
    if type(callback_token) ~= 'number' then
        error('callback_token must be a number', 3)
    end
end

local function send_try_get_data(data_set, key, callback_token)
    data_set = double_escape(data_set)
    key = double_escape(key)

    local message = concat {data_get_tag, callback_token, ' {', 'data_set:"', data_set, '",key:"', key, '"}'}
    raw_print(message)
end

local cancelable_callback_token = Token.register(function(data)
    local data_set = data.data_set
    local keys = requests[data_set]
    if not keys then
        return
    end

    local key = data.key
    local callbacks = keys[key]
    if not callbacks then
        return
    end

    keys[key] = nil

    for c, _ in next, callbacks do
        local func = Token.get(c)
        func(data)
    end
end)

--- Gets data from the web server's persistent data storage.
-- The callback is passed a table {data_set: string, key: string, value: any}.
-- If the value is nil, it means there is no stored data for that data_set key pair.
-- @param  data_set<string>
-- @param  key<string>
-- @param  callback_token<token>
-- @usage
-- local Server = require 'features.server'
-- local Token = require 'utils.token'
--
-- local callback =
--     Token.register(
--     function(data)
--         local data_set = data.data_set
--         local key = data.key
--         local value = data.value -- will be nil if no data
--
--         game.print(data_set .. ':' .. key .. ':' .. tostring(value))
--     end
-- )
--
-- Server.try_get_data('my data set', 'key 1', callback)
function Public.try_get_data(data_set, key, callback_token)
    validate_arguments(data_set, key, callback_token)

    send_try_get_data(data_set, key, callback_token)
end

local function try_get_data_cancelable(data_set, key, callback_token)
    local keys = requests[data_set]
    if not keys then
        keys = {}
        requests[data_set] = keys
    end

    local callbacks = keys[key]
    if not callbacks then
        callbacks = {}
        keys[key] = callbacks
    end

    if callbacks[callback_token] then
        return
    end

    if next(callbacks) then
        callbacks[callback_token] = true
    else
        callbacks[callback_token] = true
        send_try_get_data(data_set, key, cancelable_callback_token)
    end
end

--- Same Server.try_get_data but the request can be cancelled by calling
-- Server.cancel_try_get_data(data_set, key, callback_token)
-- If the request is cancelled before it is complete the callback will be called with data.cancelled = true.
-- It is safe to cancel a non-existent or completed request, in either case the callback will not be called.
-- There can only be one request per data_set, key, callback_token combo. If there is already an ongoing request
-- an attempt to make a new one will be ignored.
-- @param  data_set<string>
-- @param  key<string>
-- @param  callback_token<token>
function Public.try_get_data_cancelable(data_set, key, callback_token)
    validate_arguments(data_set, key, callback_token)

    try_get_data_cancelable(data_set, key, callback_token)
end

local function cancel_try_get_data(data_set, key, callback_token)
    local keys = requests[data_set]
    if not keys then
        return false
    end

    local callbacks = keys[key]
    if not callbacks then
        return false
    end

    if callbacks[callback_token] then
        callbacks[callback_token] = nil

        local func = Token.get(callback_token)
        local data = {data_set = data_set, key = key, cancelled = true}
        func(data)

        return true
    else
        return false
    end
end

--- Cancels the request. Returns false if the request could not be cnacled, either because there is no request
-- to cancel or it has been completed or cancled already. Otherwise returns true.
-- If the request is cancelled before it is complete the callback will be called with data.cancelled = true.
-- It is safe to cancel a non-existent or completed request, in either case the callback will not be called.
-- @param  data_set<string>
-- @param  key<string>
-- @param  callback_token<token>
function Public.cancel_try_get_data(data_set, key, callback_token)
    validate_arguments(data_set, key, callback_token)

    return cancel_try_get_data(data_set, key, callback_token)
end

local timeout_token = Token.register(function(data)
    cancel_try_get_data(data.data_set, data.key, data.callback_token)
end)

--- Same as Server.try_get_data but the request is cancelled if the timeout expires before the request is complete.
-- If the request is cancelled before it is complete the callback will be called with data.cancelled = true.
-- There can only be one request per data_set, key, callback_token combo. If there is already an ongoing request
-- an attempt to make a new one will be ignored.
-- @param  data_set<string>
-- @param  key<string>
-- @param  callback_token<token>
-- @usage
-- local Server = require 'features.server'
-- local Token = require 'utils.token'
--
-- local callback =
--     Token.register(
--     function(data)
--         local data_set = data.data_set
--         local key = data.key
--
--          game.print('data_set: ' .. data_set .. ', key: ' .. key)
--
--         if data.cancelled then
--             game.print('Timed out')
--             return
--         end
--
--         local value = data.value -- will be nil if no data
--
--         game.print('value: ' .. tostring(value))
--     end
-- )
--
-- Server.try_get_data_timeout('my data set', 'key 1', callback, 60)
function Public.try_get_data_timeout(data_set, key, callback_token, timeout_ticks)
    validate_arguments(data_set, key, callback_token)

    try_get_data_cancelable(data_set, key, callback_token)

    Task.set_timeout_in_ticks(timeout_ticks, timeout_token,
        {data_set = data_set, key = key, callback_token = callback_token})
end

--- Gets all the data for the data_set from the web server's persistent data storage.
-- The callback is passed a table {data_set: string, entries: {dictionary key -> value}}.
-- If there is no data stored for the data_set entries will be nil.
-- @param  data_set<string>
-- @param  callback_token<token>
-- @usage
-- local Server = require 'features.server'
-- local Token = require 'utils.token'
--
-- local callback =
--     Token.register(
--     function(data)
--         local data_set = data.data_set
--         local entries = data.entries -- will be nil if no data
--         local value2 = entries['key 2']
--         local value3 = entries['key 3']
--     end
-- )
--
-- Server.try_get_all_data('my data set', callback)
function Public.try_get_all_data(data_set, callback_token)
    if type(data_set) ~= 'string' then
        error('data_set must be a string', 2)
    end
    if type(callback_token) ~= 'number' then
        error('callback_token must be a number', 2)
    end

    data_set = double_escape(data_set)

    local message = concat {data_get_all_tag, callback_token, ' {', 'data_set:"', data_set, '"}'}
    raw_print(message)
end

local function data_set_changed(data)
    local handlers = data_set_handlers[data.data_set]
    if handlers == nil then
        return
    end

    if _DEBUG then
        for _, handler in ipairs(handlers) do
            local success, err = pcall(handler, data)
            if not success then
                log(err)
                ErrorLogging.generate_error_report(err)
                error(err, 2)
            end
        end
    else
        for _, handler in ipairs(handlers) do
            local success, err = pcall(handler, data)
            if not success then
                log(err)
                ErrorLogging.generate_error_report(err)
            end
        end
    end
end

--- Register a handler to be called when the data_set changes.
-- The handler is passed a table {data_set:string, key:string, value:any}
-- If value is nil that means the key was removed.
-- The handler may be called even if the value hasn't changed. It's up to the implementer
-- to determine if the value has changed, or not care.
-- To prevent desyncs the same handlers must be registered for all clients. The easiest way to do this
-- is in the control stage, i.e before on_init or on_load would be called.
-- @param  data_set<string>
-- @param  handler<function>
-- @usage
-- local Server = require 'features.server'
-- Server.on_data_set_changed(
--     'my data set',
--     function(data)
--         local data_set = data.data_set
--         local key = data.key
--         local value = data.value -- will be nil if data was removed.
--     end
-- )
function Public.on_data_set_changed(data_set, handler)
    if _LIFECYCLE == _STAGE.runtime then
        error('cannot call during runtime', 2)
    end
    if type(data_set) ~= 'string' then
        error('data_set must be a string', 2)
    end

    local handlers = data_set_handlers[data_set]
    if handlers == nil then
        handlers = {handler}
        data_set_handlers[data_set] = handlers
    else
        handlers[#handlers + 1] = handler
    end
end

--- Called by the web server to notify the client that a data_set has changed.
Public.raise_data_set = data_set_changed

--- Called by the web server to determine which data_sets are being tracked.
function Public.get_tracked_data_sets()
    local message = {data_tracked_tag, '['}

    for k, _ in pairs(data_set_handlers) do
        k = double_escape(k)

        local message_length = #message
        message[message_length + 1] = '"'
        message[message_length + 2] = k
        message[message_length + 3] = '"'
        message[message_length + 4] = ','
    end

    if message[#message] == ',' then
        remove(message)
    end

    message[#message + 1] = ']'

    message = concat(message)
    raw_print(message)
end

local function escape(str)
    return str:gsub('\\', '\\\\'):gsub('"', '\\"')
end

--- Called by the web server to set the server time.
-- @param  secs<number> unix epoch timestamp
function Public.set_time(secs)
    server_time.secs = secs
    server_time.tick = game.tick
end

--- Gets a table {secs:number?, tick:number} with secs being the unix epoch timestamp
-- for the server time and ticks the number of game ticks ago it was set.
-- @return table
function Public.get_time_data_raw()
    return server_time
end

--- Gets an estimate of the current server time as a unix epoch timestamp.
-- If the server time has not been set returns nil.
-- The estimate may be slightly off if within the last minute the game has been paused, saving or overwise,
-- or the game speed has been changed.
-- @return number?
function Public.get_current_time()
    local secs = server_time.secs
    if secs == nil then
        return nil
    end

    local diff = game.tick - server_time.tick
    return math.floor(secs + diff / game.speed / 60)
end

--- Called by the web server to set the server start data.
function Public.set_start_data(data)
    start_data.server_id = data.server_id
    start_data.server_name = data.server_name

    local start_time = start_data.start_time
    if not start_time then
        -- Only set start time if it has not been set already, so that we keep the first start time.
        start_data.start_time = data.start_time
    end
end

--- Gets the server's id e.g. '7'. Empty string if not known.
-- This is the current server's id, in the case the save has been loaded on multiple servers.
-- @return string
function Public.get_server_id()
    return start_data.server_id or ''
end

--- Gets the server's name e.g. '[color=red]RedMew[/color] - Crash Site Desert'. Empty string if not known.
-- This is the current server's name, in the case the save has been loaded on multiple servers.
-- @return string
function Public.get_server_name()
    return start_data.server_name or ''
end

--- Gets the server's start time as a unix epoch timestamp. nil if not known.
-- This is the time that the save was fist started/loaded on any Redmew server in the
-- case that the save has been loaded multiple times.
-- @return number?
function Public.get_start_time()
    return start_data.start_time
end

--- Called be the web server to re sync which players are online.
function Public.query_online_players()
    local message = {query_players_tag, '['}

    for _, p in ipairs(game.connected_players) do
        message[#message + 1] = '"'
        local name = escape(p.name)
        message[#message + 1] = name
        message[#message + 1] = '",'
    end

    if message[#message] == '",' then
        message[#message] = '"'
    end

    message[#message + 1] = ']'

    message = concat(message)
    raw_print(message)
end

--- Sets the server time as the scenario version. Imperfect since we ideally want the commit,
-- but an easy way to at least establish a baseline.
local function set_scenario_version()
    -- A 1 hour buffer is in place to account for potential playtime pre-upload.
    if game.tick < 216000 and not storage.redmew_version then
        local time_string = Timestamp.to_string(Public.get_current_time())
        storage.redmew_version = string.format('Time of map launch: %s UTC', time_string)
    end
end

Event.add(Public.events.on_server_started, set_scenario_version)

--- The [JOIN] nad [LEAVE] messages Factorio sends to stdout aren't sent in all cases of
--  players joining or leaving. So we send our own [PLAYER-JOIN] and [PLAYER-LEAVE] tags.
Event.add(defines.events.on_player_joined_game, function(event)
    local player = game.get_player(event.player_index)
    if not player then
        return
    end

    raw_print(player_join_tag .. player.name)
end)

local leave_reason_map = {
    [defines.disconnect_reason.quit] = '',
    [defines.disconnect_reason.dropped] = ' (Dropped)',
    [defines.disconnect_reason.reconnect] = ' (Reconnect)',
    [defines.disconnect_reason.wrong_input] = ' (Wrong input)',
    [defines.disconnect_reason.desync_limit_reached] = ' (Desync limit reached)',
    [defines.disconnect_reason.cannot_keep_up] = ' (Cannot keep up)',
    [defines.disconnect_reason.afk] = ' (AFK)',
    [defines.disconnect_reason.kicked] = ' (Kicked)',
    [defines.disconnect_reason.kicked_and_deleted] = ' (Kicked)',
    [defines.disconnect_reason.banned] = ' (Banned)',
    [defines.disconnect_reason.switching_servers] = ' (Switching servers)'
}

Event.add(defines.events.on_player_left_game, function(event)
    local player = game.get_player(event.player_index)
    if not player then
        return
    end

    local reason = leave_reason_map[event.reason] or ''
    raw_print(player_leave_tag .. player.name .. reason)
end)

Event.add(defines.events.on_player_died, function(event)
    local player = game.get_player(event.player_index)

    if not player or not player.valid then
        return
    end

    local cause = event.cause

    local message = {discord_bold_tag, player.name}
    if cause and cause.valid then
        message[#message + 1] = ' was killed by '

        local name = cause.name
        if name == 'character' then
            name = cause.player.name
        else
            message[#message + 1] = 'a '
        end

        message[#message + 1] = name
        message[#message + 1] = '.'
    else
        message[#message + 1] = ' has died.'
    end

    local position = player.physical_position
    message[#message + 1] = ' [gps='
    message[#message + 1] = string.format('%.1f', position.x)
    message[#message + 1] = ','
    message[#message + 1] = string.format('%.1f', position.y)
    message[#message + 1] = ','
    message[#message + 1] = player.physical_surface.name
    message[#message + 1] = ']'

    message = concat(message)
    raw_print(message)
end)

return Public
