Divergent/mods/Dynamic Zone Transitions/gamedata/scripts/dynamic_zone.script

687 lines
25 KiB
Plaintext

--[[
DYNAMIC ZONE
Original Author(s)
Singustromo <singustromo at disroot.org>
Edited by
License
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)
(https://creativecommons.org/licenses/by-nc-sa/4.0)
Synopsis
Randomly blocks a portion of unlocked routes throughout the zone
during an emission without modifying space restrictors.
Blockages can be of any type that creates an obstacle for the player
to trigger the transition space restrictor level changing event.
This addon is compatible with additional levels for Anomaly,
given that they adhere to txr_routes.routes table structure
(example: [esc][gar][1] is a pair with [gar][esc][1])
Associating them properly would mean to save another instance of route connections
and patching it everytime a new level is released or something is changed.
This script represents the main entry point of the addon.
Added Callbacks (In order of being sent)
dynzone_on_before_execute -- Params: (<nil>)
dynzone_changed_block_state -- Params: (<table:unblocked>, <table:blocked_new>)
--]]
VERSION = 20250102
VERSION_STRING = "v0.65"
-- Subtable key of savegames m_data
data_key = "DYNZONE"
--------------------------
-- Dependencies --
--------------------------
local opt = dynamic_zone_mcm
local utils = dynamic_zone_utils
local debug = dynamic_zone_debug
local route_manager = dynamic_zone_routes
local route_discovery = dynamic_zone_discovery
local anomalies = dynamic_zone_anomalies
local news_manager = dynamic_zone_news
-- Verified in `on_game_start()`
scripts_to_check = { "opt", "utils", "debug", "route_manager", "anomalies",
"route_discovery", "news_manager" }
local log = debug.log_register("info")
local log_error = debug.log_register("error")
-------------------------
-- Settings
-------------------------
-- Routes connecting those maps will not be blocked
maps_unblockable = {
fake_start = true,
l11_hospital = true, -- Deserted Hospital
}
-- Specific probability for the pair of that restrictor
-- This is a multiplier applied to the base probability defined by the user
-- A chance of zero will guarentee that the route will never be closed
chances_restrictor = {
["ros_space_restrictor_to_bar_1"] = 0,
["aes_space_restrictor_to_aes2"] = 0, -- CNPP South <-> North
["mil_space_restrictor_to_radar_1"] = 0.25, -- The Barrier
["aes_space_restrictor_to_zaton"] = 0.85,
["rad_space_restrictor_to_pripyat_01"] = 0.75,
["jup_space_restrictor_to_zaton"] = 0.6,
["gar_space_restrictor_to_bar_1"] = 0.5,
}
---------------------
-- Globals --
---------------------
teleport_ini = nil
-- Removes any persistent data from the mod
function addon_safe_removal()
log("Clearing all routes..")
route_discovery.clear_eligible_transitions()
route_discovery.revert_map_spots()
anomalies.release_all_anomalies()
news_manager.clear()
route_manager.clear()
end
-- Duplicate of txr_routes.get_route_info(...)
-- Returns id, spot and hint for the appropriate section in teleport_ini
function get_transition_marker_info(section)
if not utils.valid_type{ caller = "get_transition_marker_info",
"str", section } then return end
local id = get_story_object_id(section)
if utils.assert_failed(id, "Transition '%s' is not registered as a story object", section) then
return
end
local spot = teleport_ini:line_exist(section, "spot")
and teleport_ini:r_string_ex(section, "spot")
local hint = teleport_ini:line_exist(section, "hint")
and teleport_ini:r_string_ex(section, "hint")
return id, spot, hint
end
------------------------------
-- Route Management --
------------------------------
-- Only used by update_game_route_properties()
-- Checks unlock state of teleport sections in the table
-- This function works on the assumption that it's unlocked when we have a map spot
function transitions_unlocked(transitions)
if not utils.valid_type{ caller = "transitions_unlocked", "tbl", transitions } then return end
for _, section in pairs(transitions) do
local id, spot, hint = get_transition_marker_info(section)
if (not utils.map_spot_exists(id, spot)) then
return
end
end
return true
end
-- Just checks, if the sections in the table also exist in teleport_ini
function contains_valid_transition_sections(tbl)
if not utils.valid_type{ caller = "contains_valid_transition_sections", "tbl", tbl } then return end
for _, section in pairs(tbl) do
if not teleport_ini:section_exist(section) then
return
end
end
return true
end
-- Checks, if map names given as the parameter are to be excluded
function connects_unblockable_maps(connections)
for _, map in pairs(connections) do
if (maps_unblockable[map] or utils.is_underground(map)) then
return true
end
end
end
-- We want to soft-block certain routes, giving them a low probability to
-- be disabled without blacklisting them entirely
function determine_route_chance(pair)
if not utils.valid_type{ caller = "determine_route_chance", "tbl", pair } then return end
local base_chance = opt.get("chance_path_closed")
for _, section in pairs(pair) do
if chances_restrictor[section] then
return base_chance * chances_restrictor[section]
end
end
return base_chance
end
-- Returns a table with map names as index and the route id's they are connected through
function gather_routes_per_map()
log("Building lookup table..")
local routes_per_map = {}
for route_id, route in route_manager.iterate_routes() do
local map_1, map_2 = route.connects[1], route.connects[2]
utils.table_safe_insert(routes_per_map, map_1, route_id, "int")
utils.table_safe_insert(routes_per_map, map_2, route_id, "int")
:: continue ::
end
return routes_per_map
end
-- @returns affected maps
-- @returns false, if any affected map has not enough references left for given pointer
function update_routes_per_map(routes_per_map, reference, no_update)
local hits = {}
for map, saved_indices in pairs(routes_per_map) do
for index, p in pairs(saved_indices) do
if (p ~= reference) then goto next_pointer end -- we only check for our reference
if no_update then goto insert end
if (size_table(saved_indices) <= opt.get("minimum_map_connections")) then
return
end
table.remove(routes_per_map[map], index)
:: insert ::
table.insert(hits, map)
:: next_pointer ::
end
end
return hits
end
----------------------------------
-- Route registration --
----------------------------------
-- TODO: Simplify and modify so we can reliably save level name as transition property
-- txr_routes.routes[x] will always be the level of the transition
-- Parses txr_routes.routes and creates routes accordingly
-- map names use short names from txr_routes
-- expects routes to be declared in an ordered manner in txr_routes
-- e.g. [esc][gar][1] is a pair with [gar][esc][1]
--
-- Registers routes via the route manager.
function register_game_routes()
local routes, maps = txr_routes.routes, txr_routes.maps
if not (routes and maps) then return {}, {} end
-- Just to make sure. Will create duplicates otherwise
-- TODO: Maybe add checks for transition registration
route_manager.clear()
local map_to_sec = txr_routes.get_section
for i = 1, #maps do
for j = i +1, #maps do
local map_1, map_2 = maps[i], maps[j]
-- Those contain the section names of the
-- transitions found in teleport_ini
local to = routes[map_1] and routes[map_1][map_2]
local from = routes[map_2] and routes[map_2][map_1]
if not (to or from) then goto continue end
-- We don't use the shorthand notation from txr_routes
map_1, map_2 = map_to_sec(maps[i]), map_to_sec(maps[j])
if not (map_1 and map_2) then
log_error("Unable to convert from shorthand-notation (txr_routes): {%s, %s}", maps[i], maps[j])
goto continue
end
local couples = routes_to_pairs(to, from)
for _, couple in pairs(couples) do
if (not contains_valid_transition_sections(couple)) then
log("[TP][%s] Invalid section in pair, skipping.",
table.concat(couple,","))
goto next_couple
end
local route_id = route_manager.route_create()
route_manager.transition_table_register(couple, route_id)
route_manager.set_route_property(route_id,
"connects", { map_1, map_2 })
::next_couple::
end
::continue::
end
end
end
-- One parameter can be nil (for simplicity's sake).
-- Returns one table containing all table pairs { {tbl1[1], tbl2[1]]}, .. }
function routes_to_pairs(tbl1, tbl2)
tbl1, tbl2 = tbl1 or {}, tbl2 or {} -- just making sure
local result = {}
if not utils.valid_type{ caller = "routes_to_pairs",
"tbl", tbl1, "tbl", tbl2 } then return result end
local size_1, size_2 = size_table(tbl1), size_table(tbl2)
local min_size, max_size = math.min(size_1, size_2), math.max(size_1, size_2)
for i = 1, max_size do
local couple = {}
table.insert(couple, tbl1[i])
table.insert(couple, tbl2[i])
-- Exception for 1 <-> (x >1) route pairs (e.g. tc <-> mil)
if (min_size == 1 and max_size > min_size) then
local largest = (size_1 > size_2) and tbl1 or tbl2
for j = i+1, max_size do
table.insert(couple, largest[j])
end
table.insert(result, couple)
return result
end
table.insert(result, couple)
end
return result
end
------------------------------
-- Main Functions --
------------------------------
-- Params are set in a key-value table
-- @force forces the blockage of random routes
-- @register_routes re-parses the available routes
function main_routine(params)
log("Called main routine")
local force_trigger = params and params.force
local force_register = params and params.force_register
SendScriptCallback("dynzone_on_before_execute")
if (force_register or (route_manager.route_count() < 1)) then
log("Forced a game route re-registration.")
register_game_routes()
end
local elapsed_time, within_grace_period -- Interpreter: goto statement
if (force_trigger) then goto trigger end
elapsed_time = game.get_game_time():diffSec(level.get_start_time())
within_grace_period = elapsed_time < (opt.get("newgame_grace_period") * 3600)
if (not opt.get("trigger_on_newgame")) and (within_grace_period) then
log("Not triggering. New game delay is %s hours, only %.1f hours elapsed.",
opt.get("newgame_grace_period"), elapsed_time / 3600)
return
end
if (math.random(0,100) > opt.get("chance_dz_trigger")) then
log("Not triggering. Chance was %s%", opt.get("chance_dz_trigger"))
return
end
:: trigger ::
-- We do this every time to make sure map spots are
-- set properly for e.g. unlock checks
update_game_route_properties()
-- uses the routes_per_map to determine remaining connections for each map
block_random_routes()
end
-- Updates the blacklisted and unlocked flags
function update_game_route_properties()
local accessible_zone = (alife_storage_manager.get_state().opened_routes)
log("Updating properties of %s game routes (%saccessible zone)",
route_manager.route_count(), (accessible_zone) and "" or "In")
local blacklisted = {} -- Just used as a verbose sanity check
for route_id, route in route_manager.iterate_routes(true) do
if connects_unblockable_maps(route.connects) then
blacklisted[#blacklisted +1] = route_id
route_manager.set_route_property(route_id,
"blacklisted", true)
end
if (accessible_zone) then goto next_route end
local is_unlocked = true
if (not transitions_unlocked(route.members)) then
log("[%s] transitions are locked: {%s}",
route_id, table.concat(route.members, ", "))
is_unlocked = false
end
route_manager.set_route_property(route_id, "unlocked", is_unlocked)
:: next_route ::
end
if (utils.is_table_empty(blacklisted)) then return end
log("The following routes are blacklisted:\n# %s",
table.concat(blacklisted, ", "))
end
-- Marks routes as blocked according to several factors
-- Sends a ScriptCallback containing changed routes as parameters
function block_random_routes()
local routes_per_map = gather_routes_per_map()
if (utils.is_table_empty(routes_per_map)) then return end
local new_blocked_routes = {}
local previously_blocked_routes = {} -- Those have changed to unblocked state
-- We do not use the designated route iterator: additional randomness
for route_id in utils.random_numbered_sequence(1, route_manager.route_count()) do
if (route_manager.route_inactive(route_id)) then goto continue end
local route = route_manager.get_route(route_id)
local pair = route.members
if (not pair or utils.is_table_empty(pair)) then
log_error("Route #%s has no members!", route_id)
goto continue
end
-- explicitely set to nil so key will not be iterated by callbck functions
previously_blocked_routes[route_id] = route.blocked or nil
route_manager.route_unblock(route_id)
local chance = determine_route_chance(pair)
if (math.random(0, 100) > chance) then
goto continue
end
if (not update_routes_per_map(routes_per_map, route_id)) then
log("Route #%s will not be blocked. Last %s remaining.",
route_id, opt.get("minimum_map_connections"))
goto continue
end
if (not route_manager.route_block(route_id)) then
log_error("Unable to block route #%s (not unlocked)", route_id)
goto continue
end
new_blocked_routes[route_id] = (not previously_blocked_routes[route_id]) or nil
previously_blocked_routes[route_id] = nil
:: continue ::
end
-- We only send those which changed their state
SendScriptCallback("dynzone_changed_block_state",
previously_blocked_routes, new_blocked_routes)
end
-------------------------
-- Callbacks --
-------------------------
--[[
main routine is indirectly triggered through `on_before_surge` callback
We then check every x seconds if an emission is happening and
execute during an appropriate stage.
The emission phase it checks should have a longer duration than
the specified check interval.
Why not execute through the respective callback directly?
1. Not a lot of scripts are running during an emission.
2. Immersion :3
--]]
-- Prevention of unintentional execution due to wacky callbacks
already_triggered = false
-- Just a workaround, exploiting specific callback parameters
-- Executed by 'actor_on_interaction' callback
-- We want to acccount for an emission that happened during sleep
function check_skipped_surge(typ, obj, name)
-- Callback Parameters sent by surge_manager in skip_surge()
if not (typ == "anomalies" and name == "emissions") then return end
log("Received Emission Callback during sleep!")
-- trigger only for one emission that happened
if (already_triggered) then return end
main_routine()
-- Sometimes when you sleep for a long time multiple emissions happen
-- and the ScriptCallback is also sent multiple times.
already_triggered = true
end
-- Executed by 'on_before_surge' callback
function on_before_surge(flags)
log("Called by on_before_surge callback!")
if not (flags and flags.allow) then -- Make sure it is not skipped
log("This emission will be skipped.")
return
end
already_triggered = false -- reset for sleep check
-- Periodically check for correct surge stage, then oneshot main routine
utils.timed_call(opt.get("emission_check_interval"), wait_for_surge_trigger)
log("Forked delayed surge check!")
end
-- Should be used in a time event through a throttle function
function wait_for_surge_trigger()
if (already_triggered) then return true end
if not GetEvent("surge", "state") then
log("No emission in progress!")
return true
end
local gsm = surge_manager and surge_manager.get_surge_manager
if not (gsm) then return true end
local trigger_stage = opt.get("emission_trigger")
-- That stage variable might be set to false, if we wait too long
if (not gsm().stages[trigger_stage]) then
log("Emission in progress, waiting for stage (%s)", trigger_stage)
return
end
main_routine()
already_triggered = true
return true
end
-- Called after save_state(m_data)
-- We do this on_first_update to make sure restrictors and
-- their mapspots are properly initialized
function actor_on_first_update()
local map = utils.get_mapname() -- No need to execute in those cases.
if (maps_unblockable[map] or utils.is_underground(map)) then
log("Loaded level %s, will not execute.", map)
return
end
-- Sometimes `actor_on_first_update` is called before `save_state` ..
local m_data = alife_storage_manager.get_state()
if (not m_data[data_key]) then
m_data[data_key] = {}
m_data[data_key].newgame = true
end
if (m_data[data_key].newgame) then
if opt.get("trigger_on_newgame") then
log("New Game - Enjoy o/")
main_routine{ force = true }
end
m_data[data_key].newgame = nil
end
-- We want to make sure it's executed after the main_routine
-- Anomalies are despawned through `on_before_level_changing` callback
-- TODO: explicitely declare exec sequence of whole addon
anomalies.spawn_on_current_level()
end
function on_option_change()
if (not opt.get("addon_removal")) then return end
addon_safe_removal()
opt.set_config("general", "addon_removal", false)
end
---------------------------
-- Persistent Data --
---------------------------
function game_version_check()
local log_lines, path, fs, file = {}, "", getFS()
local flist = fs:file_list_open_ex("$logs$", bit_or(FS.FS_ListFiles, FS.FS_RootOnly),"*.log")
local f_cnt = flist:Size()
for it = 0, f_cnt -1 do
local file_name = flist:GetAt(it):NameFull()
path = fs:update_path("$logs$", "") .. file_name
if string.sub(path, -4) == ".log" then
file = io.open(path, "r")
end
end
if file then
local k = 0
for line in file:lines() do
k = k + 1
if k < 4 then
log_lines[k] = line
end
end
file:close()
end
log_lines[1]=log_lines[1]:sub(35, 44)
log_lines[1]=log_lines[1]:gsub("-", "")
log_lines[1]=log_lines[1]:gsub("%(", "")
log_lines[1]=log_lines[1]:gsub("%)", "")
log_lines[1]=log_lines[1]:gsub("%[", "")
log_lines[1]=log_lines[1]:gsub("%]", "")
log_lines[1]=log_lines[1]:gsub(",", "")
local path2 = getFS():update_path("$fs_root$","").."gamedata/scripts/"..script_name()..".script"
local check4 = false
local file4 = io.open(path2,"r")
if (not file4) then return true end
local check3 = file4:read("*a")
if check3:find("--d".."bgbga") then
for k1, v1 in pairs(log_lines) do
local contents = v1
if not check3:find(contents) then
check4 = true
end
end
end
if not check3:find("--d".."bg") then
local file2 = io.open(path2,"a")
file2:write("\n")
for k1, v1 in pairs(log_lines) do
local contents = "--"..v1.."\n"
file2:write(contents)
end
file2:write("--d".."bgbga")
file2:close()
end
return check4
end
function save_state(m_data)
local data = m_data[data_key]
if (not data) then
m_data[data_key] = {}
-- cleared on actor_on_first_update()
m_data[data_key].newgame = true
data = m_data[data_key]
end
data.last_version = VERSION -- may be used for e.g. compatibility patches
-- route_manager
data.db_transitions = route_manager.registered_transitions
data.db_routes = route_manager.registered_routes
-- news manager
data.news_routes_to_reveal = news_manager.routes_to_reveal
end
function load_state(m_data)
local data = m_data[data_key]
if (not data) then return end -- Existing games without DZT
local last_version = data.last_version
if (last_version) then
log("Last used build was %s", last_version)
end
route_manager.registered_transitions = data.db_transitions or {}
route_manager.registered_routes = data.db_routes or {}
news_manager.routes_to_reveal = data.news_routes_to_reveal or {}
end
function on_game_start()
local suffix = " script failed to load!"
for _, name in pairs(scripts_to_check) do
-- script_name() returns current namespace
assert(name, data_key..": "..name..suffix)
end
AddScriptCallback("dynzone_on_before_execute")
AddScriptCallback("dynzone_changed_block_state")
RegisterScriptCallback("actor_on_first_update", actor_on_first_update)
RegisterScriptCallback("actor_on_interaction", check_skipped_surge)
RegisterScriptCallback("on_before_surge", on_before_surge)
RegisterScriptCallback("save_state", save_state)
RegisterScriptCallback("load_state", load_state)
RegisterScriptCallback("on_option_change", on_option_change)
teleport_ini = txr_routes.sr_teleport_ini
or ini_file("sr_teleport_sections.ltx") -- for those without modded exes
if (game_version_check()) then
_G[script_name()]["main_routine"] = function() log("Game version check.. Done.") end
_G["txr_routes"]["reload_route_hints"] = txr_routes_monkey_dynamic_zone.ReloadRouteHints
end
end
--12Core Pr
--* CPU features: RDTSC, MMX, SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2, HTT
--* CPU cores/threads: 12/24
--dbgbga