ops/factorio/multiworld: init

This commit is contained in:
Luke Granger-Brown 2022-02-05 17:17:39 +00:00
parent cc6fd576e7
commit ee8ec5263a
8 changed files with 830 additions and 0 deletions

View file

@ -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;

View file

@ -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;
}

7
ops/factorio/default.nix Normal file
View file

@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2022 Luke Granger-Brown <depot@lukegb.com>
#
# SPDX-License-Identifier: Apache-2.0
args: {
multiworld = import ./multiworld args;
}

View file

@ -0,0 +1 @@
keep_simple_control_block_one_line: false

View file

@ -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 <WORLDNAME>` 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 <WORLD NAME>` - goes directly to the world
### `/createworld <WORLD NAME> <MAP EXCHANGE STRING>` - 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 <WORLD NAME>` - 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 <WORLD NAME>` - 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`.

View file

@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: 2022 Luke Granger-Brown <depot@lukegb.com>
#
# 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
''

141
ops/factorio/multiworld/infect.py Executable file
View file

@ -0,0 +1,141 @@
#!@python3@
# SPDX-FileCopyrightText: 2022 Luke Granger-Brown <depot@lukegb.com>
#
# SPDX-License-Identifier: Apache-2.0
"""
USAGE: %s [flags] <zip to patch>
"""
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)

View file

@ -0,0 +1,578 @@
-- 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
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 <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 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)
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