--[[ DYNAMIC ZONE Original Author(s) Singustromo 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: () dynzone_changed_block_state -- Params: (, ) --]] 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 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 end