The instance works out the recipe notification delta against the cleared
list the player had when the download started, but the save applied it
after the download restored player.force. A player whose force changed
kept notifications they had cleared elsewhere, and recipes only the old
force had dropped out of the failed list and were lost on the next
upload.
The download record now keeps the cleared list it sent. The save rebuilds
the stored list from it and the delta, then merges that against the
force the player has once the data is applied, as it did before #997.
Nothing extra goes over RCON.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Review asked for the spectator to move freely while it waits. Only a jump
of more than 16 tiles from where it was last seen, or to another surface,
counts as a teleport and moves the restore location. The body place names
from #1156 become restore_location, keep_restore_location and
controller_taken.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A player waiting for player_sync data is a spectator who could walk away,
so restore_position teleported them back at the end. That also undid
teleports by other mods made during the sync, such as universal_edges
placing an arriving player.
The spectator is now pulled back on every tile change, and a jump of more
than 4 tiles or to another surface counts as another mod's teleport and
moves where the body comes back. A failed acquire that takes the body
keeps its place too.
Closes#908
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"safe" suggested the data being written survives a crash, it only
guarantees the old file is either kept or fully replaced. The new name
says what it does. No alias, plugin API breaks are allowed in minor
versions and the 2.1 migration guide lists the rename.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
Factorio encodes an empty Lua table as {}, so a player with no cleared
recipes sent {} with the download request. new Set({}) threw, the
instance dropped the recipe notifications from the download, and the
next upload replaced the stored list on the controller. An empty stored
list was dropped the same way. Decode both lists treating {} as empty.
Replace ESLint, typescript-eslint, eslint-plugin-node and the flat
config helpers with vite-plus, which runs Oxlint for vp lint. The rules
are carried over from eslint.config.mjs by @oxlint/migrate into the lint
section of the root vite.config.ts. The formatting rules ESLint
deprecated run as @stylistic rules through Oxlint's JS plugin support,
and disable comments naming them are updated to the @stylistic names.
@stylistic understands TypeScript syntax that the core rules skipped,
so the autofix adds missing semicolons and spaces in type literals and
aliases. Leading | and & in multiline types stay where they were.
Oxlint's node/no-sync also reports identifiers ending in Sync that are
not calls. The test helper techSync is renamed and the two other names
are ignored.
Refs #1127
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace webpack and Module Federation with Vite 8. The controller build
emits one entry per shared module (lib, web_ui, typebox, antd, react,
react-dom, react-router) and lists them in manifest.json. The controller
turns that into an import map in index.html, and plugins are built as
ES modules that import the shared modules by name and are loaded with
import().
The shared Vite setup lives in @clusterio/web_ui/vite.mjs. Plugins use
clusterioPlugin(), which exposes index, package.json and web/index under
the same keys the webpack template used. The --dev and --dev-plugin
flags now run the Vite dev server in middleware mode with hot module
replacement.
Browser polyfills move from web_ui to lib, which is the code that needs
them, and lib sets the process and Buffer globals itself.
Refs #1127
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The download a join forces after dropping a copy that took the body found the
player already a spectator, so it kept the spectator's place and restored the
character there. It now takes the surface, position and vehicle the dropped
copy kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The leave handler skipped the upload for any active download, including a
prefetched copy no join had confirmed, which has replaced nothing here. Newer
local data, such as after a failed upload whose retry the join cleared, was then
never uploaded. Only a real download skips it now; a copy whose join took the
body still uploads nothing through sync_in_progress.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A copy confirmed at an earlier join has replaced the body with a spectator.
If a later join drops it (another controller run) while the generations match,
the join kept the local state: a spectator with no body, uploaded on the next
leave. Download instead. A newer copy replacing a held one keeps where the
body stood, so the replacement still counts as having taken it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An admin import or delete bumps the stored data's epoch and can start the
generations over, so a copy held from before it must not apply. The prefetch
carries the epoch like the token, and a held copy is used only when it matches
the acquire reply's.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An instance with older data can upload the generation a save already holds, and a prefetch of it then overwrote that
save's newer state. A join now drops a prefetched copy unless the ordinary download check says the data is needed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Chunks of an earlier controller run's copy still in flight could land in a newer run's copy of the same generation. Every chunk now carries the token, and the scenario ignores a chunk whose token differs from its record's.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If the controller crashes after sending generation N but before saving it, the destination keeps that prefetch while the controller restarts at N-1, and another instance can then upload different data as generation N. The controller now picks a random token per run, sends it with every prefetch and acquire reply, and the scenario applies a held copy only when its generation and token both match, and lets a new run's copy replace one with another token.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A prefetched copy is sent without the save's list of cleared recipes, so
its delta had only add, and a recipe cleared on the destination but not
in the stored list stayed cleared where a download would give it its
notification back. Without a current list the instance now sends the
whole stored list marked full, and the save gives the recipes it has
cleared that aren't in it their notification back, as a download's
remove does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
player_sync.prefetch_inventory holds a copy sent ahead in active_downloads
and finished_downloads, for a player who is offline or doesn't exist on
the destination, with the components its first chunk names. Chunk 1
refuses a player who is online, a download in progress, a stream of the
same or a newer generation and a finished copy of a newer one; a finished
copy of the same generation is replaced. At the acquire reply a copy is
used only when the reply is acquired with its generation and its
components are the ones the scenario syncs now. The player is then
switched to spectator as a download does and the copy is applied, at once
or at its last chunk. Otherwise the copy is dropped and the join
continues as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A plugin that moves a player calls remote.call("player_sync",
"set_destination", player_name, instance_id) before connect_to_server.
It only records the destination. Every leave clears it, and a leave
that uploads with reason switching_servers adds it to the upload as
destination_instance_id. The README describes it for plugin authors.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The instance passes destination_instance_id from a leave upload on as
destinationInstanceId, and sends a PrefetchEvent's data to
player_sync.prefetch_inventory in RCON chunks, using the download's
chunk loop. As for a download, the data is stripped by the instance's
own player_sync.sync_* options and the components go with the first
chunk. A non-empty reply means the save refused or ignored the copy and
stops the transfer.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An upload can carry destinationInstanceId. Once the upload is stored,
the controller sends the stored data, the upload merged with its copy,
in a PrefetchEvent to that instance, if it is another running instance
with player_sync enabled. No lock is taken or released. A failed send is
logged and doesn't fail the upload.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The drawer actions move to its header as Export, Import and a trash
icon for Delete. Import and Delete are disabled while an instance
holds the player, since the stored data is out of date until it
uploads. The drawer also shows color and cheat mode.
Inventory tables get title case names, pagination at 10 rows, item
search, sorting and quality icons. The player list shows force with
a filter instead of generation, and the database stats use
Descriptions without the largest entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Imports no longer accept the cutscene controller, which needs waypoints
to restore. Logistic filters and the basic item stack fields now have the
types and limits Factorio enforces, and the inventory summary skips
malformed items and counts repeated exported items once instead of
expanding them.
Lua's JSON loses precision past 2^50, so a generation there could no
longer be counted up and uploads were discarded. Imports now start over
from generation 1 when the stored or imported generation is at that limit.
Instances holding a copy from another epoch take the stored generation
instead of keeping their own higher one when there is no data to download.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Imports and uploads could carry deleted: false, which the loader took for
a tombstone on the next restart. The schemas now reject the property, and
the loader only treats deleted: true as a tombstone and strips the key from
entries already saved with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An import with color but no chat_color or tag assigned nil to the missing
LuaPlayer properties and failed restoration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The raise covered the whole gap between the serialized main inventory
and the main inventory with the armor restored, but only the bonus of
the armor's equipment arrives at the end of the tick. When the
destination force has a smaller character_inventory_slots_bonus than
the source, the rest of the gap never arrived and removing the raise on
the armor event dropped those slots on the ground. The raise is now the
equipment's inventory bonus minus what the grid already applies, at
most the gap, and the slots past it overflow into a corpse as without
the raise. Factorio 1.1 has no inventory bonus equipment and gets no
raise.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
on_player_armor_inventory_changed for the restored armor is raised at
the end of the download's tick, after the inventory bonus of the armor's
equipment applied, so the raise is removed there instead of in the next
tick's on_tick. The raise is only made for armor with a grid, as the
event needs the armor to be restored.
A player leaving on the tick of a download no longer has the raise
removed from the character first, which shrank the main inventory
before the bonus applied and spilled the extra slots. The raise is
taken off the uploaded inventory bonus instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The inventory bonus of equipment in the armor's grid (toolbelts) is
applied by the engine on the tick after the armor is inserted. The main
inventory was restored in the same tick, so the items of every slot past
80 plus research plus the armor's own bonus did not fit and were put in
a corpse: legendary mech armor with legendary toolbelts left 2125 of
2330 slots in the corpse.
The armor is now restored first, the character's inventory bonus is
raised to fit the saved main inventory, and the raise is removed again
on the next tick, or before an upload if the player leaves first.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Logistic slots saved before 2.0 are keyed by index strings, which
json_to_table keeps as strings, so the migration branch never ran and
the 2.0 branch failed on the missing sections. Detect the old format by
the missing sections and convert the keys to numbers.
The import schema now uses the minimums Factorio enforces for character
stats, checks the filter value type and the quick bar comparator, and
allows filters without quality or comparator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An import with `crafting_queue: {}` passed the schema and broke the
next restore in deserialize_crafting_queue. The import schema now
checks the members module/serialize.lua reads from the crafting queue,
quick bar, personal logistic slots and character stats, while still
allowing a component to be left out.
Character stats missing from the data are left as they are instead of
being set to nil.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A host running the plugin from before epochs never sends one, so after
an import or delete every upload it made was discarded while the
instance believed it succeeded. The Lua now always sends its epoch, and
an upload without one is compared by generation against the stored
data the way it was before, taking over the stored epoch. Those hosts
can still overwrite an import with a copy they failed to upload, which
the README now says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tombstones lived in player_sync_deleted.json, written after
player_sync.json. A stop between the two writes lost the tombstone of a
fresh delete, and the next upload from an old copy brought the deleted
data back. Deleted players are now entries with deleted: true in
player_sync.json, so both are written in one safeOutputFile call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An import with an unknown controller or malformed inventories replaced
the stored snapshot and bumped its epoch, then broke the player's next
join. SetPlayerDataRequest now checks the fields the scenario restores,
leaving item stacks to the Lua deserializer and allowing the partial
snapshots instances with disabled components upload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An import, delete or release for one player that finished after another
player was selected reloaded the drawer with the first player's data
under the second player's name. The drawer body is now keyed by player,
so a late completion can only update the view it was started from.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generations alone could not order a repair against copies held by
instances. Deleting dropped the watermark, so a stale instance could
skip downloading the new baseline. An import could land on the same
generation as an upload that never reached the controller, and the
instance would then keep its own copy.
Imports and deletes now bump a per-player epoch, which the controller
returns on acquire and checks on upload. Instances throw away a copy
from an earlier epoch, and deleted players keep their generation and
epoch in player_sync_deleted.json. A restarted download also takes the
new generation instead of the outdated one.
Generations in player data must now be non-negative integers, so an
invalid import is refused before it reaches the datastore.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
InstanceDetailsGetRequest needs core.instance.get, which the
player-sync list and show commands are not documented to require.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Factorio's table_to_json writes an empty inventory as {"i":{}}, which
made the summary throw for show and the web drawer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request for one player could resolve after the drawer switched to
another and show its inventory, and export it, under the wrong name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov flagged ctl.ts at 21% patch coverage. Run each command
through a fake control link with canned responses and check what it
prints and sends.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Admins had no way to look at or fix the player data the controller
stores when a sync went wrong. The web page now lists every stored
entry with its generation, size and the instance holding the lock, and
opens a drawer per player with an inventory summary, JSON export and
import, delete and lock release. The same operations are exposed as
clusterioctl player-sync commands.
Import bumps the generation past the stored one so instances holding an
older copy download the new data on the next join. Import and delete
are refused while an instance holds the lock, since its upload on leave
would undo the change. A new player_sync.inventory.modify permission
gates the write operations.
Closes#1003
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The scenario no longer sends its components with the download request. The instance strips the download by its own config and sends the components it used with the first chunk. If they differ from the ones the scenario started the download with, because the config changed in between, the scenario restarts the download.
A pending download is now marked with sync_in_progress instead of clearing dirty while spectating, and it is set for every download rather than only when inventories are synced. Uploads are skipped while it is set, so a player who leaves during a download with inventories off no longer uploads their stale state.
SyncComponent names the components in the Lua annotations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
serialize_local_controller now respawns a player waiting to respawn for a moment to read the logistic requests hidden in the ghost, when logistics are not synced. Without this the requests were lost when the download turned the ghost into a spectator.
deserialize_player compares the physical controller with the one it restores. A player in remote view with a character was seen as changing controller, so with inventories off their crafting queue was serialized and queued a second time on the same character.
The ghost branch only sets the force when the force is synced, it errored on the stripped field otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
place_items no longer creates script inventories for what does not fit. Each serialized inventory is split at the size of its destination: the slots that fit are restored slot for slot and the rest become serialized stacks, which are inserted into the free space of the same inventory and then main, guns, ammo and armor. What is still left over is returned as a list and spill_items puts it into a corpse sized to hold it. This keeps 1.0 out of reach of game.create_inventory.
The armor-first fill order is gone, as the bonus slots from armor equipment only apply on the next tick anyway (#1106). Filters are no longer stripped for destinations, they are the same inventory type the data came from.
deserialize_inventory concatenated the filter table in its error print on 2.0, which errored out the whole restore when an inventory did not support filters.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>