From ee8ec5263a6e16543084346aedb9294c1948eec5 Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Sat, 5 Feb 2022 17:17:39 +0000 Subject: [PATCH] ops/factorio/multiworld: init --- ci-root.nix | 1 + ops/default.nix | 1 + ops/factorio/default.nix | 7 + ops/factorio/multiworld/.lua-format | 1 + ops/factorio/multiworld/README.md | 61 +++ ops/factorio/multiworld/default.nix | 40 ++ ops/factorio/multiworld/infect.py | 141 ++++++ ops/factorio/multiworld/multiworld.lua | 578 +++++++++++++++++++++++++ 8 files changed, 830 insertions(+) create mode 100644 ops/factorio/default.nix create mode 100644 ops/factorio/multiworld/.lua-format create mode 100644 ops/factorio/multiworld/README.md create mode 100644 ops/factorio/multiworld/default.nix create mode 100755 ops/factorio/multiworld/infect.py create mode 100644 ops/factorio/multiworld/multiworld.lua diff --git a/ci-root.nix b/ci-root.nix index bdf95957a3..04f63358dc 100644 --- a/ci-root.nix +++ b/ci-root.nix @@ -28,6 +28,7 @@ let twitternuke = depot.go.twitternuke; systemPathJSON = depot.ops.nixos.systemPathJSON; }; + factorio = depot.ops.factorio; }; x86_64-darwin = { home-manager = depot.ops.home-manager-ext.built; diff --git a/ops/default.nix b/ops/default.nix index a28999503c..bba61cc2d4 100644 --- a/ops/default.nix +++ b/ops/default.nix @@ -7,6 +7,7 @@ args: { maint = import ./maint args; secrets = import ./secrets args; raritan = import ./raritan args; + factorio = import ./factorio args; home-manager-ext = import ./home-manager-ext.nix args; } diff --git a/ops/factorio/default.nix b/ops/factorio/default.nix new file mode 100644 index 0000000000..c422f71467 --- /dev/null +++ b/ops/factorio/default.nix @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2022 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 + +args: { + multiworld = import ./multiworld args; +} diff --git a/ops/factorio/multiworld/.lua-format b/ops/factorio/multiworld/.lua-format new file mode 100644 index 0000000000..bced11e8f5 --- /dev/null +++ b/ops/factorio/multiworld/.lua-format @@ -0,0 +1 @@ +keep_simple_control_block_one_line: false diff --git a/ops/factorio/multiworld/README.md b/ops/factorio/multiworld/README.md new file mode 100644 index 0000000000..5673ce3976 --- /dev/null +++ b/ops/factorio/multiworld/README.md @@ -0,0 +1,61 @@ +# Factorio Multiworld + +This is a very simple hack to allow Factorio vanilla servers to run with +multiple-world support. + +It enables the use of a `/ws` command which opens a GUI to switch between +worlds. If you already know the name of the world, then `/ws ` can +be used to quickly switch. + +Different worlds have separate research progress, and players have separate +inventories as they transition between worlds. + +Worlds can be created from a map exchange string with the `/createworld` +command by admins, and destroyed using `/destroyworld`. + +## Commands + +### `/ws` - world switcher + +The `/ws` is the bulk of the multiworld functionality, and the only command accessible by non-admins. + +It has two forms: + +* `/ws` - opens a world selection GUI with a button per world +* `/ws ` - goes directly to the world + +### `/createworld ` - create a new world (admins only) + +`/createworld` is one way of creating a new, empty world based on a given map +exchange string. + +Note that only the parameters of the map exchange string that pertain to +generation settings will be respected: difficulty, and other settings that +Factorio retains per-map rather than per-surface will be ignored. + +### `/destroyworld ` - destroy a world (admins only) + +`/destroyworld` deletes a world by name, and deletes all the inventories +associated with it. + +### `/debugeditor` - open the editor (admins only) + +`/debugeditor` opens the map editor without tripping the "editor opened" +achievement disabling flag. + +This is intended for use for admins to import existing save files as new +surfaces. + +Note that once you've done so, you'll need to use the `/debugresetworldstate` +command in order to properly set up the extra metadata required by Multiworld. + +### `/debugresetworldstate ` - reset book-keeping metadata for a world (admins only) + +This resets various internal metadata: + +* The per-world player force +* Any inventories held for players for that world +* Any locations held for players for that world + +It should usually be used only after importing an existing save file as a new +surface using the `/debugeditor`. diff --git a/ops/factorio/multiworld/default.nix b/ops/factorio/multiworld/default.nix new file mode 100644 index 0000000000..d1f007e231 --- /dev/null +++ b/ops/factorio/multiworld/default.nix @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2022 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 + +{ pkgs, ... }: + +let + python3Env = pkgs.python3.withPackages (pm: with pm; [ + absl-py + ]); + + src = pkgs.runCommand "factorio-multiworld-infect-src" { + nativeBuildInputs = [ + (pkgs.python3.withPackages (pm: with pm; [ + black + ])) + pkgs.luaformatter + ]; + src = ./.; + } '' + mkdir $out + cp $src/infect.py $out/infect.py + black --check $out/infect.py + cp $src/multiworld.lua $out/multiworld.lua + cp $src/.lua-format ./. + lua-format --check $out/multiworld.lua + ''; +in pkgs.runCommand "factorio-multiworld-infect" { + python3 = "${python3Env}/bin/python"; + + infectPy = "${src}/infect.py"; + multiworldlua = "${src}/multiworld.lua"; +} '' + mkdir -p $out/bin + + substitute $infectPy $out/bin/factorio-multiworld-infect \ + --subst-var python3 \ + --subst-var multiworldlua + chmod +x $out/bin/factorio-multiworld-infect +'' diff --git a/ops/factorio/multiworld/infect.py b/ops/factorio/multiworld/infect.py new file mode 100755 index 0000000000..560baf138c --- /dev/null +++ b/ops/factorio/multiworld/infect.py @@ -0,0 +1,141 @@ +#!@python3@ +# SPDX-FileCopyrightText: 2022 Luke Granger-Brown +# +# SPDX-License-Identifier: Apache-2.0 +""" + USAGE: %s [flags] +""" + +import pathlib +import shutil +import tempfile +import zipfile + +from absl import app +from absl import flags +from absl import logging + +FLAGS = flags.FLAGS + +flags.DEFINE_boolean( + "backup", + True, + "Back up world before modifying it to file with .pre-multiworld-infect suffix", +) +flags.DEFINE_boolean( + "dry_run", + False, + "Don't actually overwrite the input file with the output", +) +flags.DEFINE_boolean( + "uninfect", + False, + "Removes the multiworld Lua rather than injecting it; note that this won't remove any of the other persisted save elements, and may leave players stranded on other surfaces.", +) + +_CONTROL_LUA_INFECT_LINE = b'handler.add_lib(require("multiworld"))' + + +def for_each_in_zip(in_zip, out_zip, callback, skip_files=None): + skip_files = skip_files or frozenset() + ret = None + for zi in in_zip.infolist(): + file_basename = pathlib.PurePosixPath(zi.filename).name + if file_basename in skip_files: + logging.info("Found %s at %s: skipping", file_basename, zi.filename) + continue + with in_zip.open(zi, "r") as fsrc: + zi.header_offset = None + with out_zip.open(zi, "w") as fdst: + cbret = callback(zi, fsrc, fdst) + ret = ret or cbret + return ret + + +def handle_control_lua(fsrc, fdst): + # Look to see if we've already infected it... + buf = fsrc.read() + lines = buf.split(b"\n") + if FLAGS.uninfect: + if _CONTROL_LUA_INFECT_LINE in lines: + logging.info("Removing infection line from control.lua") + lines.remove(_CONTROL_LUA_INFECT_LINE) + else: + logging.info("control.lua not infected, carrying on") + else: + if _CONTROL_LUA_INFECT_LINE not in lines: + logging.info("control.lua not yet infected, adding our line...") + last_handler_add_lib = None + for n, line in enumerate(lines): + if line.startswith(b"handler.add_lib("): + last_handler_add_lib = n + if last_handler_add_lib is None: + raise ValueError("Can't find handler.add_lib( lines in control.lua!") + lines.insert(last_handler_add_lib + 1, _CONTROL_LUA_INFECT_LINE) + else: + logging.info("control.lua already infected, carrying on") + + for line in lines: + fdst.write(line + b"\n") + + +def handle_file(zi, fsrc, fdst): + filepath = pathlib.PurePosixPath(zi.filename) + if filepath.name == "control.lua": + logging.info("Handling control.lua at %s", filepath) + handle_control_lua(fsrc, fdst) + return filepath.parent + + logging.info("Copying %s", filepath) + shutil.copyfileobj(fsrc, fdst, 1024 * 8) + return None + + +def main(argv): + if len(argv) != 2: + raise app.UsageError("Need exactly one argument") + + file_path = pathlib.Path(argv[1]) + + if FLAGS.backup: + backup_path = file_path.with_name(f"{file_path.name}.pre-multiworld-infect") + logging.info("Backing up %s to %s", file_path, backup_path) + shutil.copy(file_path, backup_path) + + with tempfile.TemporaryDirectory() as tmpd: + tmpd_path = pathlib.Path(tmpd) + tmp_file_path = tmpd_path / file_path.name + with zipfile.ZipFile( + tmp_file_path, "w", compression=zipfile.ZIP_DEFLATED + ) as out_zip, zipfile.ZipFile(file_path, "r") as in_zip: + parent = for_each_in_zip( + in_zip, out_zip, handle_file, skip_files={"multiworld.lua"} + ) + + if not FLAGS.uninfect: + # Add our multiworld.lua. + arcname = parent / "multiworld.lua" + logging.info( + "Adding multiworld.lua from %s to %s", "@multiworldlua@", arcname + ) + zi = zipfile.ZipInfo.from_file( + filename="@multiworldlua@", + arcname=parent / "multiworld.lua", + strict_timestamps=False, + ) + with open("@multiworldlua@", "rb") as src, out_zip.open( + zi, "w" + ) as dest: + shutil.copyfileobj(src, dest, 1024 * 8) + if FLAGS.dry_run: + logging.warning( + "In dry-run mode: not overwriting output file %s with %s", + file_path, + tmp_file_path, + ) + else: + shutil.move(tmp_file_path, file_path) + + +if __name__ == "__main__": + app.run(main) diff --git a/ops/factorio/multiworld/multiworld.lua b/ops/factorio/multiworld/multiworld.lua new file mode 100644 index 0000000000..ee55bbb43f --- /dev/null +++ b/ops/factorio/multiworld/multiworld.lua @@ -0,0 +1,578 @@ +-- SPDX-FileCopyrightText: 2022 Luke Granger-Brown +-- +-- 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 + + 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 reset_locations(surface, p) + if global.locations == nil then + global.locations = {} + end + if global.locations[surface.name] ~= nil then + p('Destroying locations for surface ' .. surface.name, c) + for player_idx, location in pairs(global.locations[surface.name]) do + p(' .. ' .. game.players[player_idx].name .. ' (' .. location.x .. + ', ' .. location.y .. ')', c) + -- We don't do anything here, this is just for logging purposes. + end + end + global.locations[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) + reset_locations(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) + reset_locations(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 ', c) + return + end + + local bits = split_whitespace(cmd.parameter) + if #bits ~= 1 then + p('/destroyworld ', 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 ', c) + return + end + + local bits = split_whitespace(cmd.parameter) + if #bits <= 2 then + p('/createworld ', 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 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 ', c) + return + end + + local bits = split_whitespace(cmd.parameter) + if #bits ~= 1 then + p('/debugresetworldstate ', 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) + reset_locations(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('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