depot/ops/factorio/multiworld/multiworld.lua

660 lines
19 KiB
Lua
Raw Normal View History

-- SPDX-FileCopyrightText: 2022 Luke Granger-Brown <depot@lukegb.com>
--
-- SPDX-License-Identifier: Apache-2.0
local c = {r = 1, g = 0.5, b = 0.1}
local c_fatal = {r = 1, g = 0.5, b = 0.5}
local function split_whitespace(s)
chunks = {}
for substring in s:gmatch("%S+") do
table.insert(chunks, substring)
end
return chunks
end
local function error_handler(doing_what, player_cause)
return function(err)
local full_err = '[' .. player_cause.name .. ' caused error while ' ..
doing_what .. '] ' .. err .. '\n' ..
debug.traceback()
print(full_err)
if not player_cause.admin then
player_cause.print('Oops, something went wrong when performing ' ..
doing_what .. ': ' .. err, c_fatal)
end
for _, player in pairs(game.connected_players) do
if player.admin then
player.print(full_err, c_fatal)
end
end
end
end
local starter_items = {
["iron-plate"] = 8,
["wood"] = 1,
["burner-mining-drill"] = 1,
["stone-furnace"] = 1
}
local starter_ammo_items = {["pistol"] = 1, ["firearm-magazine"] = 10}
local function get_surface_by_name(name)
-- Yeet.
if name == 'spawn' then
return game.get_surface('nauvis')
elseif name == 'nauvis' then
return nil
end
return game.get_surface(name)
end
local function get_surface_display_name(surface)
if surface.name == 'nauvis' then
return 'spawn'
end
return surface.name
end
local function force_name_for_surface(surface)
if surface.name == 'nauvis' then
return 'player'
end
return 'player_' .. surface.name
end
local function force_for_surface(surface)
return game.forces[force_name_for_surface(surface)]
end
local inventory_types = {
-- The order of this dictates the load order from the top.
-- The save order starts from the bottom.
-- Armor first, because it grants inventory size buffs.
defines.inventory.character_armor, defines.inventory.character_guns,
defines.inventory.character_ammo, defines.inventory.character_vehicle,
defines.inventory.character_trash, defines.inventory.character_main
}
local function save_inventory(player, inventories, inventory_type)
if inventories[inventory_type] ~= nil then
inventories[inventory_type].destroy()
end
inventories[inventory_type] = nil
local src_inventory = player.character.get_inventory(inventory_type)
if src_inventory == nil then
return
end
local dst_inventory = game.create_inventory(#src_inventory)
inventories[inventory_type] = dst_inventory
for i = 1, #src_inventory do
dst_inventory[i].swap_stack(src_inventory[i])
end
src_inventory.clear()
end
local function save_inventories(player, inventories)
for i = #inventory_types, 1, -1 do
save_inventory(player, inventories, inventory_types[i])
end
end
local function restore_inventory(player, inventories, inventory_type)
local dst_inventory = player.character.get_inventory(inventory_type)
if dst_inventory == nil then
return true
end
dst_inventory.clear()
if inventories[inventory_type] == nil then
-- Special case: we have no inventory to restore from, so give them default spawn stuff.
if inventory_type == defines.inventory.character_main then
player.print(
'Giving you the starter items, since you seem to be new to the world...',
c)
for item_name, count in pairs(starter_items) do
player.insert({name = item_name, count = count})
end
elseif inventory_type == defines.inventory.character_ammo then
for item_name, count in pairs(starter_ammo_items) do
player.insert({name = item_name, count = count})
end
end
return false
end
local src_inventory = inventories[inventory_type]
for i = 1, #src_inventory do
dst_inventory[i].swap_stack(src_inventory[i])
end
src_inventory.clear()
return true
end
local function restore_inventories(player, inventories)
for i = 1, #inventory_types do
restore_inventory(player, inventories, inventory_types[i])
end
end
local function get_saved_inventory(surface, player)
local surface_inventories = global.inventories[surface.name]
if surface_inventories == nil then
surface_inventories = {}
global.inventories[surface.name] = surface_inventories
end
local inventories = surface_inventories[player.index]
if inventories == nil then
inventories = {}
surface_inventories[player.index] = inventories
end
return inventories
end
local function get_surface_locations(surface)
if global.locations == nil then
global.locations = {}
end
local surface_locations = global.locations[surface.name]
if surface_locations == nil then
surface_locations = {}
global.locations[surface.name] = surface_locations
end
return surface_locations
end
local function port_player_to_world(player, surface)
local p = player.print
if player.crafting_queue_size > 0 then
p("Can't port between worlds while crafting", c)
return
end
player.clear_cursor()
save_inventories(player, get_saved_inventory(player.surface, player))
local src_locations = get_surface_locations(player.surface)
local dst_locations = get_surface_locations(surface)
src_locations[player.index] = player.character.position
local force = force_for_surface(surface)
local dst_location = dst_locations[player.index]
if dst_location == nil then
dst_location = force.get_spawn_position(surface)
end
local safe_dst_location = surface.find_non_colliding_position("character",
dst_location,
surface.get_starting_area_radius(),
2)
if safe_dst_location ~= nil then
dst_location = safe_dst_location
end
player.teleport(dst_location, surface)
player.force = force
restore_inventories(player, get_saved_inventory(surface, player))
end
local function port_player_to_world_by_name(player, name)
local p = player.print
local surface = get_surface_by_name(name)
if surface == nil then
p("No such world " .. name, c)
return
end
if player.character.surface == surface then
p("Already on world " .. name, c)
return
end
port_player_to_world(player, surface)
end
local function show_gui(player)
local p = player.print
if player.gui.screen["lukegb-gui"] then
player.gui.screen["lukegb-gui"].destroy()
end
local outer = player.gui.screen.add {
type = "frame",
name = "lukegb-gui",
direction = "vertical"
}
local title_flow = outer.add {
type = "flow",
name = "title_flow",
direction = "horizontal"
}
title_flow.style.horizontally_stretchable = true
title_flow.style.horizontal_spacing = 8
local title = title_flow.add {
type = "label",
caption = "World Selector",
style = "frame_title"
}
title.drag_target = outer
local title_pusher = title_flow.add {
type = "empty-widget",
style = "draggable_space_header"
}
title_pusher.style.height = 24
title_pusher.style.horizontally_stretchable = true
title_pusher.drag_target = outer
local close_button = title_flow.add {
type = "sprite-button",
style = "frame_action_button",
sprite = "utility/close_white",
tags = {lukegb_close_btn = true}
}
local cont = outer.add {type = "frame", style = "inside_shallow_frame"}
local surf_scroll = cont.add {
type = "scroll-pane",
style = "scroll_pane_under_subheader"
}
for _, surf in pairs(game.surfaces) do
local btn = surf_scroll.add {
type = "button",
caption = get_surface_display_name(surf),
style = "menu_button",
enabled = surf ~= player.surface,
tags = {lukegb_dst_world = surf.index}
}
end
outer.force_auto_center()
player.opened = outer
end
local function handle_gui_click(e)
local p
local player = game.players[e.player_index]
if player == nil then
print('Sorry, need to be in game')
return
end
p = player.print
if e.element.tags.lukegb_dst_world ~= nil then
port_player_to_world(player,
game.surfaces[e.element.tags.lukegb_dst_world])
player.opened = nil
elseif e.element.tags.lukegb_close_btn then
player.opened = nil
end
end
local function handle_gui_closed(e)
if e.gui_type ~= defines.gui_type.custom then
return
end
if e.element == nil then
return
end
if e.element.name ~= "lukegb-gui" then
return
end
e.element.destroy()
end
local function ws_command(cmd)
local p
local player = game.player
if player == nil then
print('Sorry, need to be in game')
return
end
p = player.print
if cmd.parameter ~= nil then
-- If we got a parameter, put them there.
port_player_to_world_by_name(player, cmd.parameter)
return
end
-- Otherwise, show the gui.
show_gui(player)
end
local function reset_inventories(surface, p)
if global.inventories == nil then
global.inventories = {}
end
if global.inventories[surface.name] ~= nil then
p('Destroying inventories for surface ' .. surface.name, c)
for player_idx, inventories in pairs(global.inventories[surface.name]) do
p(' .. ' .. game.players[player_idx].name, c)
for inventory_type, inventory in pairs(inventories) do
inventory.destroy()
end
end
end
global.inventories[surface.name] = {}
end
local function destroy_world(name, p)
local surface = get_surface_by_name(name)
if surface == nil then
p('No such world ' .. name, c)
return
end
if surface.name == 'nauvis' then
p('Spawn world cannot be destroyed', c)
return
end
if force_for_surface(surface) ~= nil then
p('Destroying force ' .. force_name_for_surface(surface), c)
game.merge_forces(force_name_for_surface(surface), 'player')
end
p('Destroying surface ' .. surface.name, c)
game.delete_surface(surface)
reset_inventories(surface, p)
end
local function create_world(name, map_gen_settings, p)
local surface = get_surface_by_name(name)
if surface ~= nil then
p('World named ' .. name .. ' already exists', c)
return
end
p('Creating surface ' .. name, c)
local surface = game.create_surface(name, map_gen_settings)
p('Creating force ' .. force_name_for_surface(surface), c)
local force = game.create_force(force_name_for_surface(surface))
reset_inventories(surface, p)
end
local function destroyworld_command(cmd)
local p
local player = game.player
if player == nil then
print('Sorry, need to be in game')
return
end
p = player.print
if player.admin ~= true then
p('Need to be an admin')
return
end
if cmd.parameter == nil then
p('/destroyworld <name>', c)
return
end
local bits = split_whitespace(cmd.parameter)
if #bits ~= 1 then
p('/destroyworld <name>', c)
return
end
local map_name = bits[1]
destroy_world(map_name, p)
end
local function createworld_command(cmd)
local p
local player = game.player
if player == nil then
print('Sorry, need to be in game')
return
end
p = player.print
if player.admin ~= true then
p('Need to be an admin', c)
return
end
if cmd.parameter == nil then
p('/createworld <name> <map exchange string>', c)
return
end
local bits = split_whitespace(cmd.parameter)
if #bits <= 2 then
p('/createworld <name> <map exchange string>', c)
return
end
local map_exchange_string_bits = {}
for k, v in pairs({table.unpack(bits, 2)}) do
map_exchange_string_bits[k] = v
end
local map_exchange_string = table.concat(map_exchange_string_bits, " ")
local map_name = bits[1]
local map_exchange = game.parse_map_exchange_string(map_exchange_string)
create_world(map_name, map_exchange.map_gen_settings, p)
end
local function debugeditor_command(cmd)
local p
local player = game.player
if player == nil then
print('Sorry, need to be in game')
return
end
p = player.print
if player.admin ~= true then
p('Need to be an admin', c)
return
end
player.toggle_map_editor()
end
local function debugrenameworld_command(cmd)
local p
local player = game.player
if player == nil then
print('Sorry, need to be in game')
return
end
p = player.print
if player.admin ~= true then
p('Need to be an admin', c)
return
end
if cmd.parameter == nil then
p('/debugrenameworld <name>', c)
return
end
local bits = split_whitespace(cmd.parameter)
if #bits ~= 2 then
p('/debugrenameworld <name>', c)
return
end
local old_name = bits[1]
local new_name = bits[2]
if old_name == 'nauvis' or old_name == 'spawn' then
p('Sorry, renaming the spawn world is not permitted', c)
return
end
local surface = get_surface_by_name(old_name)
if surface == nil then
p('No such world ' .. old_name, c)
return
end
-- We need to get some stuff before we rename the surface, since it's name dependent.
local force = force_for_surface(surface)
-- Rename the surface.
surface.name = new_name
p('Renamed surface ' .. old_name .. ' to ' .. new_name, c)
-- Check if we need to rename the force.
local new_force_name = force_name_for_surface(surface)
if force == nil then
p('Creating new force ' .. new_force_name)
force = game.create_force(new_force_name)
local player_owned_entities = surface.find_entities_filtered {
force = "player"
}
p('Moving everything (' .. #player_owned_entities ..
' entities) from the player force to the ' .. force.name ..
' force')
for _, entity in pairs(player_owned_entities) do
entity.force = force
end
elseif force.name ~= new_force_name then
p('Renaming force ' .. force.name .. ' to ' .. new_force_name)
local new_force = game.create_force(new_force_name)
game.merge_forces(force.name, new_force.name)
force = new_force
end
-- Move inventories and locations, if any, from the old name to the new name.
if global.inventories == nil then
global.inventories = {}
end
if global.locations == nil then
global.locations = {}
end
local old_surface_inventories = global.inventories[old_name]
if old_surface_inventories == nil then
old_surface_inventories = {}
end
local old_surface_locations = global.locations[old_name]
if old_surface_locations == nil then
old_surface_locations = {}
end
global.inventories[new_name] = old_surface_inventories
global.locations[new_name] = old_surface_locations
end
local function debugresetworldstate_command(cmd)
local p
local player = game.player
if player == nil then
print('Sorry, need to be in game')
return
end
p = player.print
if player.admin ~= true then
p('Need to be an admin', c)
return
end
if cmd.parameter == nil then
p('/debugresetworldstate <name>', c)
return
end
local bits = split_whitespace(cmd.parameter)
if #bits ~= 1 then
p('/debugresetworldstate <name>', c)
return
end
local map_name = bits[1]
local surface = get_surface_by_name(map_name)
if surface == nil then
p('No such world ' .. map_name, c)
return
end
if force_for_surface(surface) ~= nil then
p('Destroying force ' .. force_name_for_surface(surface), c)
game.merge_forces(force_name_for_surface(surface), 'player')
end
p('Creating force ' .. force_name_for_surface(surface), c)
local force = game.create_force(force_name_for_surface(surface))
reset_inventories(surface, p)
local force = force_for_surface(surface)
local player_owned_entities = surface.find_entities_filtered {
force = "player"
}
p('Moving everything (' .. #player_owned_entities ..
' entities) from the player force to the ' .. force.name .. ' force')
for _, entity in pairs(player_owned_entities) do
entity.force = force
end
end
local function make_event_handler(name, handler)
return function(e)
xpcall(function() handler(e) end,
error_handler(name, game.players[e.player_index]))
end
end
local multiworld = {}
multiworld.events = {
[defines.events.on_gui_click] = make_event_handler("on_gui_click",
handle_gui_click),
[defines.events.on_gui_closed] = make_event_handler("on_gui_closed",
handle_gui_closed)
}
multiworld.add_commands = function()
commands.add_command('ws', 'View the list of worlds', ws_command)
commands.add_command('destroyworld', 'Destroy a world by name',
function(cmd)
xpcall(function() destroyworld_command(cmd) end,
error_handler("/destroyworld", game.player))
end)
commands.add_command('createworld',
'Create a new world given a name and a map exchange string',
function(cmd)
xpcall(function() createworld_command(cmd) end,
error_handler("/createworld", game.player))
end)
commands.add_command('debugeditor',
'Toggles into or out of the map editor without disabling achievements',
function(cmd)
xpcall(function() debugeditor_command(cmd) end,
error_handler("/debugeditor", game.player))
end)
commands.add_command('debugrenameworld',
'Renames a surface and remaps corresponding state',
function(cmd)
xpcall(function() debugrenameworld_command(cmd) end,
error_handler("/debugrenameworld", game.player))
end)
commands.add_command('debugresetworldstate',
'Resets the multiworld state for a surface without touching the surface itself',
function(cmd)
xpcall(function() debugresetworldstate_command(cmd) end,
error_handler("/debugresetworldstate", game.player))
end)
end
return multiworld