621 lines
23 KiB
Plaintext
621 lines
23 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 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
|