mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
While the host has no controller session, events between the instance and the controller are dropped in both directions, and research_sync only synced technologies on instance start. A technology finished during the outage stayed unresearched on the controller and on every other instance until the instance restarted. On a new session the instance now runs the same technology sync as on start, before taking the cluster queue. Contributions made while the controller is unreachable are summed per technology and sent on connect or resume, since progress from several instances can't be rebuilt from a state sync that keeps the highest progress. A finish made while the connection was dropped triggers the sync on resume too. sync_technologies now runs while the game is running, so it skips writing progress to a technology that finished after the dump. Fixes #1123 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
501 lines
14 KiB
Lua
501 lines
14 KiB
Lua
local clusterio_api = require("modules/clusterio/api")
|
|
local compat = require("modules/clusterio/compat")
|
|
|
|
local v2_tech_progress = compat.version_ge("2.0.0")
|
|
-- on_research_queued and player_index on moved/cancelled arrived in 2.0.56
|
|
local research_queue_api = compat.version_ge("2.0.56")
|
|
|
|
local sync = {}
|
|
|
|
--- @param tech LuaTechnology
|
|
local function get_technology_progress(tech)
|
|
if tech == tech.force.current_research then
|
|
return tech.force.research_progress
|
|
elseif v2_tech_progress then
|
|
return tech.saved_progress
|
|
else
|
|
--- @diagnostic disable-next-line
|
|
return tech.force.get_saved_technology_progress(tech.name)
|
|
end
|
|
end
|
|
|
|
--- @param tech LuaTechnology
|
|
--- @param progress number
|
|
local function set_technology_progress(tech, progress)
|
|
if tech == tech.force.current_research then
|
|
tech.force.research_progress = progress
|
|
elseif v2_tech_progress then
|
|
tech.saved_progress = progress
|
|
else
|
|
--- @diagnostic disable-next-line
|
|
tech.force.set_saved_technology_progress(tech.name, progress)
|
|
end
|
|
end
|
|
|
|
--- @class ResearchSyncTechnology
|
|
--- @field level uint
|
|
--- @field researched boolean?
|
|
--- @field progress double?
|
|
|
|
--- @class ResearchSyncData
|
|
--- @field technologies table<string, ResearchSyncTechnology>
|
|
--- @field ignore_research_finished boolean
|
|
--- @field follow boolean? nil until the first queue_state sets it: shown ticked, but doesn't send or apply
|
|
--- @field ignore_queue_events boolean
|
|
--- @field queue_dirty boolean
|
|
--- @field queue_player string?
|
|
|
|
--- @param no_early_return boolean?
|
|
--- @return ResearchSyncData
|
|
local function get_script_data(no_early_return)
|
|
local research_sync = compat.script_data.research_sync
|
|
if research_sync and not no_early_return then
|
|
return research_sync
|
|
end
|
|
|
|
research_sync = research_sync or {}
|
|
research_sync.technologies = research_sync.technologies or {}
|
|
|
|
if research_sync.ignore_research_finished == nil then
|
|
research_sync.ignore_research_finished = false
|
|
end
|
|
if research_sync.ignore_queue_events == nil then
|
|
research_sync.ignore_queue_events = false
|
|
end
|
|
if research_sync.queue_dirty == nil then
|
|
research_sync.queue_dirty = false
|
|
end
|
|
|
|
local force = game.forces["player"]
|
|
for _, tech in pairs(force.technologies) do
|
|
local progress = get_technology_progress(tech)
|
|
research_sync.technologies[tech.name] = {
|
|
level = tech.level,
|
|
researched = tech.researched,
|
|
progress = progress,
|
|
}
|
|
end
|
|
|
|
compat.script_data.research_sync = research_sync
|
|
return research_sync
|
|
end
|
|
|
|
sync.events = {}
|
|
sync.events[clusterio_api.events.on_server_startup] = function(event)
|
|
get_script_data(true)
|
|
end
|
|
|
|
--- @param tech LuaTechnology
|
|
local function get_contribution(tech)
|
|
local progress = get_technology_progress(tech)
|
|
if not progress then
|
|
return 0, nil
|
|
end
|
|
|
|
local script_data = get_script_data()
|
|
local prev_tech = script_data.technologies[tech.name]
|
|
if prev_tech.progress and prev_tech.level == tech.level then
|
|
return progress - prev_tech.progress, progress
|
|
else
|
|
return progress, progress
|
|
end
|
|
end
|
|
|
|
--- @param tech LuaTechnology
|
|
local function send_contribution(tech)
|
|
local contribution, progress = get_contribution(tech)
|
|
if contribution ~= 0 then
|
|
clusterio_api.send_json("research_sync:contribution", {
|
|
name = tech.name,
|
|
level = tech.level,
|
|
contribution = contribution,
|
|
})
|
|
local script_data = get_script_data()
|
|
script_data.technologies[tech.name].progress = progress
|
|
end
|
|
end
|
|
|
|
--- @param event EventData.on_research_started
|
|
sync.events[defines.events.on_research_started] = function(event)
|
|
local tech = event.last_research
|
|
if tech then
|
|
send_contribution(tech)
|
|
end
|
|
end
|
|
|
|
--- @param event EventData.on_research_finished
|
|
sync.events[defines.events.on_research_finished] = function(event)
|
|
local script_data = get_script_data()
|
|
if script_data.ignore_research_finished then
|
|
return
|
|
end
|
|
|
|
local tech = event.research
|
|
script_data.technologies[tech.name] = {
|
|
level = tech.level,
|
|
researched = tech.researched,
|
|
}
|
|
|
|
local level = tech.level
|
|
if not tech.researched then
|
|
level = level - 1
|
|
end
|
|
|
|
clusterio_api.send_json("research_sync:finished", {
|
|
name = tech.name,
|
|
level = level,
|
|
})
|
|
end
|
|
|
|
sync.on_nth_tick = {}
|
|
sync.on_nth_tick[79] = function(event)
|
|
local tech = game.forces["player"].current_research
|
|
if tech then
|
|
send_contribution(tech)
|
|
end
|
|
end
|
|
|
|
--- @param force LuaForce
|
|
--- @return string[]
|
|
local function queue_names(force)
|
|
local names = {}
|
|
for _, tech in ipairs(force.research_queue) do
|
|
table.insert(names, tech.name)
|
|
end
|
|
return names
|
|
end
|
|
|
|
--- @param force LuaForce
|
|
--- @param names string[]
|
|
local function write_queue(force, names)
|
|
local script_data = get_script_data()
|
|
script_data.ignore_queue_events = true
|
|
local ok, err = pcall(function() force.research_queue = names end)
|
|
script_data.ignore_queue_events = false
|
|
if not ok then
|
|
log("research_sync: failed to write research queue: " .. tostring(err))
|
|
end
|
|
end
|
|
|
|
--- @param names string[]
|
|
--- @return string
|
|
local function format_queue(names)
|
|
if #names == 0 then
|
|
return "(empty)"
|
|
end
|
|
local tags = {}
|
|
for _, name in ipairs(names) do
|
|
table.insert(tags, "[technology=" .. name .. "]")
|
|
end
|
|
return table.concat(tags, " ")
|
|
end
|
|
|
|
--- @param player LuaPlayer
|
|
local function ensure_gui(player)
|
|
local frame = player.gui.relative["research_sync_queue"]
|
|
if not frame then
|
|
frame = player.gui.relative.add {
|
|
type = "frame",
|
|
name = "research_sync_queue",
|
|
caption = "Research sync",
|
|
anchor = {
|
|
gui = defines.relative_gui_type.lab_gui,
|
|
position = defines.relative_gui_position.right,
|
|
},
|
|
}
|
|
frame.add {
|
|
type = "checkbox",
|
|
name = "research_sync_follow",
|
|
caption = "Follow the cluster research queue",
|
|
tooltip = "On: this server's research queue is replaced by the cluster's, "
|
|
.. "and changes here change it on every following server.\n"
|
|
.. "Off: this server's research queue is cleared and it picks its own research.\n"
|
|
.. "Either way, all research progress is counted, whatever technology is selected.",
|
|
state = true,
|
|
}
|
|
end
|
|
frame["research_sync_follow"].state = get_script_data().follow ~= false
|
|
end
|
|
|
|
--- @param event EventData.on_player_created
|
|
sync.events[defines.events.on_player_created] = function(event)
|
|
if not research_queue_api then
|
|
return
|
|
end
|
|
ensure_gui(game.get_player(event.player_index) --[[@as LuaPlayer]])
|
|
end
|
|
sync.events[defines.events.on_player_joined_game] = sync.events[defines.events.on_player_created]
|
|
|
|
--- @param event EventData.on_gui_checked_state_changed
|
|
sync.events[defines.events.on_gui_checked_state_changed] = function(event)
|
|
if not research_queue_api then
|
|
return
|
|
end
|
|
if event.element.name == "research_sync_follow" then
|
|
local player = game.get_player(event.player_index) --[[@as LuaPlayer]]
|
|
research_sync.set_follow(event.element.state, player.name)
|
|
end
|
|
end
|
|
|
|
-- The version check stays outside here: these defines don't exist before 2.0.56, so indexing them there would error.
|
|
if research_queue_api then
|
|
--- @param event EventData.on_research_queued|EventData.on_research_moved|EventData.on_research_cancelled
|
|
local function on_queue_event(event)
|
|
if event.force.name ~= "player" then
|
|
return
|
|
end
|
|
local script_data = get_script_data()
|
|
if script_data.follow ~= true or script_data.ignore_queue_events or script_data.ignore_research_finished then
|
|
return
|
|
end
|
|
script_data.queue_dirty = true
|
|
-- the last event of the tick decides who gets named
|
|
local player = event.player_index and game.get_player(event.player_index)
|
|
script_data.queue_player = player and player.name or nil
|
|
end
|
|
sync.events[defines.events.on_research_queued] = on_queue_event
|
|
sync.events[defines.events.on_research_moved] = on_queue_event
|
|
sync.events[defines.events.on_research_cancelled] = on_queue_event
|
|
end
|
|
|
|
-- One send per tick however many events fired (a lab raises several at once).
|
|
--- @param event EventData.on_tick
|
|
sync.events[defines.events.on_tick] = function(event)
|
|
if not research_queue_api then
|
|
return
|
|
end
|
|
local script_data = get_script_data()
|
|
if not script_data.queue_dirty then
|
|
return
|
|
end
|
|
script_data.queue_dirty = false
|
|
local player = script_data.queue_player
|
|
script_data.queue_player = nil
|
|
if script_data.follow ~= true then
|
|
return
|
|
end
|
|
clusterio_api.send_json("research_sync:queue", {
|
|
queue = queue_names(game.forces["player"]),
|
|
player = player,
|
|
})
|
|
end
|
|
|
|
research_sync = {}
|
|
function research_sync.dump_technologies()
|
|
local force = game.forces["player"]
|
|
|
|
local techs = {}
|
|
for _, tech in pairs(force.technologies) do
|
|
table.insert(techs, {
|
|
name = tech.name,
|
|
level = tech.level,
|
|
progress = get_technology_progress(tech),
|
|
researched = tech.researched,
|
|
})
|
|
end
|
|
|
|
if #techs == 0 then
|
|
rcon.print("[]")
|
|
else
|
|
rcon.print(compat.table_to_json(techs))
|
|
end
|
|
end
|
|
|
|
--- @param data string
|
|
function research_sync.sync_technologies(data)
|
|
local force = game.forces["player"]
|
|
|
|
local nameIndex = 1
|
|
local levelIndex = 2
|
|
local progressIndex = 3
|
|
local researchedIndex = 4
|
|
|
|
local script_data = get_script_data()
|
|
script_data.ignore_research_finished = true
|
|
for _, tech_data in pairs(compat.json_to_table(data) --[[@as table]]) do
|
|
local tech = force.technologies[tech_data[nameIndex]]
|
|
if tech and tech.level <= tech_data[levelIndex] then
|
|
local new_level = math.min(tech_data[levelIndex], tech.prototype.max_level)
|
|
if new_level ~= tech.level then
|
|
-- when the level of the current research changes the
|
|
-- progress is not automatically reset.
|
|
if force.current_research == tech then
|
|
force.research_progress = 0
|
|
end
|
|
tech.level = new_level
|
|
end
|
|
|
|
local progress
|
|
if tech_data[researchedIndex] then
|
|
if force.current_research == tech then
|
|
force.research_progress = 0
|
|
end
|
|
tech.researched = true
|
|
progress = nil
|
|
-- may have finished here since the dump this sync is answering
|
|
elseif tech_data[progressIndex] and not tech.researched then
|
|
send_contribution(tech)
|
|
progress = tech_data[progressIndex]
|
|
set_technology_progress(tech, progress)
|
|
else
|
|
progress = get_technology_progress(tech)
|
|
end
|
|
|
|
script_data.technologies[tech.name] = {
|
|
level = tech.level,
|
|
researched = tech.researched,
|
|
progress = progress,
|
|
}
|
|
end
|
|
end
|
|
script_data.ignore_research_finished = false
|
|
end
|
|
|
|
--- @param data string
|
|
function research_sync.update_progress(data)
|
|
local script_data = get_script_data()
|
|
local techs = compat.json_to_table(data) --[[@as table]]
|
|
local force = game.forces["player"]
|
|
|
|
for _, controllerTech in ipairs(techs) do
|
|
local tech = force.technologies[controllerTech.name]
|
|
if tech and tech.level == controllerTech.level then
|
|
send_contribution(tech)
|
|
set_technology_progress(tech, controllerTech.progress)
|
|
script_data.technologies[tech.name] = {
|
|
level = tech.level,
|
|
progress = controllerTech.progress
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
--- @param name string
|
|
--- @param level number
|
|
function research_sync.research_technology(name, level)
|
|
local force = game.forces["player"]
|
|
local tech = force.technologies[name]
|
|
if not tech or tech.level > level then
|
|
return
|
|
end
|
|
|
|
if level > tech.prototype.max_level then
|
|
level = tech.prototype.max_level
|
|
end
|
|
|
|
local script_data = get_script_data()
|
|
script_data.ignore_research_finished = true
|
|
if tech == force.current_research and tech.level == level then
|
|
force.research_progress = 1
|
|
|
|
elseif tech.level < level or tech.level == level and not tech.researched then
|
|
tech.level = level
|
|
tech.researched = true
|
|
|
|
if tech.name:find("-%d+$") then
|
|
game.print {"", "Researched ", {"technology-name." .. tech.name:gsub("-%d+$", "")}, " ", level}
|
|
else
|
|
game.print {"", "Researched ", {"technology-name." .. tech.name}}
|
|
end
|
|
game.play_sound { path = "utility/research_completed" }
|
|
end
|
|
script_data.ignore_research_finished = false
|
|
|
|
script_data.technologies[tech.name] = {
|
|
level = tech.level,
|
|
researched = tech.researched,
|
|
}
|
|
end
|
|
|
|
function research_sync.queue_state()
|
|
if not research_queue_api then
|
|
rcon.print('{"follow":false}')
|
|
return
|
|
end
|
|
local script_data = get_script_data()
|
|
local first = script_data.follow == nil
|
|
if first then
|
|
script_data.follow = true
|
|
end
|
|
rcon.print(compat.table_to_json({
|
|
follow = script_data.follow,
|
|
first = first,
|
|
queue = queue_names(game.forces["player"]),
|
|
}))
|
|
end
|
|
|
|
--- @param data string JSON {queue: string[], player?: string, instance?: string}
|
|
function research_sync.apply_queue(data)
|
|
if not research_queue_api then
|
|
return
|
|
end
|
|
local msg = compat.json_to_table(data) --[[@as table]]
|
|
local queue = msg.queue or {}
|
|
if msg.instance then
|
|
local list = format_queue(queue)
|
|
if msg.player then
|
|
game.print(msg.player .. " changed the research queue on " .. msg.instance .. ": " .. list)
|
|
else
|
|
game.print("The research queue was changed on " .. msg.instance .. ": " .. list)
|
|
end
|
|
end
|
|
|
|
if get_script_data().follow ~= true then
|
|
return
|
|
end
|
|
local force = game.forces["player"]
|
|
-- Names this tile has no technology for (a mod it lacks) would throw.
|
|
local known = {}
|
|
for _, name in ipairs(queue) do
|
|
if force.technologies[name] then
|
|
table.insert(known, name)
|
|
end
|
|
end
|
|
local current = queue_names(force)
|
|
local same = #known == #current
|
|
for i = 1, #known do
|
|
if known[i] ~= current[i] then
|
|
same = false
|
|
break
|
|
end
|
|
end
|
|
if not same then
|
|
write_queue(force, known)
|
|
end
|
|
end
|
|
|
|
--- @param enabled boolean
|
|
--- @param player_name string?
|
|
function research_sync.set_follow(enabled, player_name)
|
|
if not research_queue_api then
|
|
return
|
|
end
|
|
local script_data = get_script_data()
|
|
if script_data.follow == enabled then
|
|
return
|
|
end
|
|
script_data.follow = enabled
|
|
script_data.queue_dirty = false
|
|
script_data.queue_player = nil
|
|
for _, player in pairs(game.players) do
|
|
ensure_gui(player)
|
|
end
|
|
|
|
local state = enabled and "on" or "off"
|
|
if player_name then
|
|
game.print(player_name .. " turned " .. state .. " following the cluster research queue")
|
|
else
|
|
game.print("Following the cluster research queue was turned " .. state)
|
|
end
|
|
|
|
local force = game.forces["player"]
|
|
if enabled then
|
|
clusterio_api.send_json("research_sync:follow", { queue = queue_names(force), player = player_name })
|
|
else
|
|
write_queue(force, {})
|
|
end
|
|
end
|
|
|
|
|
|
return sync
|