--[[ DYNAMIC ZONE - PDA News Original Author(s) Singustromo VodoXleb 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) --]] parent = _G["dynamic_zone"] if not (parent and parent.VERSION and parent.VERSION >= 20241224) then return end -------------------------- -- 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 --------------------------------- -- Globals & Constants -- --------------------------------- CONST_LOGGING_PREFIX = "Dynamic News" local log = debug.log_register("info", CONST_LOGGING_PREFIX) local log_warn = debug.log_register("warning", CONST_LOGGING_PREFIX) local log_err = debug.log_register("error", CONST_LOGGING_PREFIX) CONST_RANDOMIZER_BEZIER_PARAMETERS = { 0, .0, .2, .9 } -- 41.9% CONST_PROPAGATION_INTERVAL_MS = 20000 CONST_FALLBACK_DELAY_MIN_MS = 500 CONST_FALLBACK_DELAY_MAX_MS = CONST_PROPAGATION_INTERVAL_MS * 0.5 CONST_MESSAGE_MAX_STRINGS_TO_ITERATE = 20 CONST_STRING_ID_PREFIX = "st_dynzone_news" CONST_STRING_ID_FALLBACK = CONST_STRING_ID_PREFIX .. "_fallback" CONST_STRING_ID_SUFFIX_BLOCKED = "blocked" CONST_STRING_ID_SUFFIX_OPENED = "opened" CONST_NEWS_FALLBACK_ICON = "ui_iconsTotal_grouping" CONST_MESSAGE_FALLBACK_NAME = "Bratan" routes_to_reveal = {} -- routes whose changes will be propagated by other stalkers. function clear() log("Cleared route propagation queue") routes_to_reveal = {} end special_characters = { -- [story_id] = identifier (for strings) ["esc_m_trader"] = "sidorovich", -- Loner ["bar_dolg_general_petrenko_stalker"] = "petrenko", -- Duty ["mil_smart_terrain_7_7_freedom_leader_stalker"] = "lukash", -- Freedom -- ["mar_smart_terrain_base_stalker_leader_marsh"] = "cold", -- Clear Sky ["yan_stalker_sakharov"] = "sakharov", -- Ecologists ["cit_killers_merc_trader_stalker"] = "dushman", -- Mercenaries ["agr_smart_terrain_1_6_near_2_military_colonel_kovalski"] = "kovalski", -- Military ["zat_b7_bandit_boss_sultan"] = "sultan", -- Bandits ["pri_monolith_monolith_trader_stalker"] = "krolik", -- Monolith } ------------------------------ -- Just Shortcuts -- ------------------------------ local ctime_to_tbl = utils_data.CTime_to_table local tbl_to_ctime = utils_data.CTime_from_table -- Using bezier curves for non-linear probability and to clamp result -- Used so mean will be set percentage over time (of the time frame) function get_random_time(lower, upper) local randomizer = libmath_bezier and libmath_bezier.get_random_value if utils.assert_failed(randomizer, "Bézier script not found. Using math.random()") then return math.random(lower, upper) end return randomizer(lower, upper, CONST_RANDOMIZER_BEZIER_PARAMETERS) end ------------------------- -- Callbacks -- ------------------------- function on_game_start() RegisterScriptCallback("dynzone_changed_block_state", discover_changed_routes_via_news) RegisterScriptCallback("actor_on_update", check_timed_unlocks) end function discover_changed_routes_via_news(previously_blocked_routes, new_blocked_routes) clear() queue_routes_for_propagation(previously_blocked_routes, opt.get("percent_path_reveal_unblock"), CONST_STRING_ID_SUFFIX_OPENED) queue_routes_for_propagation(new_blocked_routes, opt.get("percent_path_reveal_block"), CONST_STRING_ID_SUFFIX_BLOCKED) RegisterScriptCallback("actor_on_update", check_timed_unlocks) end -- Iterates through the route discovery queue and -- checks if any of them are due to being propagated check_timed_unlocks = utils.throttle(CONST_PROPAGATION_INTERVAL_MS, true, function() if (utils.is_table_empty(routes_to_reveal)) then log("No routes in the news queue. Unregistered Callback.") UnregisterScriptCallback("actor_on_update", check_timed_unlocks) return end local current_ctime = game.get_game_time() log("Current time: %s", string.format("%d/%.2d/%.2d %.2d:%.2d:%.2d", current_ctime:get(Y,M,D,h,m,s,ms))) for route_id, data in pairs(routes_to_reveal) do utils.assert(data, "Undefined Route data") utils.assert(data.reveal_time, "Undefined Route reveal time") local reveal_time = tbl_to_ctime(data.reveal_time) local remaining_time_ms = (reveal_time:diffSec(current_ctime) / level.get_time_factor()) * 1000 reveal_time:sub(current_ctime) if (remaining_time_ms >= CONST_PROPAGATION_INTERVAL_MS) then local _, __, d, h, m, s, ___ = reveal_time:get(Y,M,D,h,m,s,ms) log("Route #%s will be propagated in %sd %sh %sm %ss", route_id, (d -1), h, m, s) goto continue end remaining_time_ms = (remaining_time_ms < 0) and math.random(CONST_FALLBACK_DELAY_MIN_MS, CONST_FALLBACK_DELAY_MAX_MS) or remaining_time_ms log("Route #%s will be propagated in %ss (realtime)", route_id, string.format("%.1f", remaining_time_ms / 1000)) utils.assert(data.reveal_type, "Undefined route reveal type!") utils.timed_call(remaining_time_ms, function() propagate_route_state_change(route_id, data.reveal_type) return true end) :: continue :: end end) -------------------------- -- Main logic -- -------------------------- function propagate_route_state_change(route_id, reveal_type) if not utils.valid_type{ caller = "propagate_route_state_change", "int", route_id } then return end local route = route_manager.get_route(route_id) local levels, sender = route and route.connects if math.random() > (opt.get("chance_special_character") / 100) then sender = find_random_stalker_on_level_pair(levels) else sender = find_special_character_on_level_pair(levels) end local route_state_differs = route_manager.route_known_state_differs(route_id) local play_discovery_sound = opt.get("news_play_discovery_sound") and route_state_differs if not utils.assert_failed(sender, "Sender for route #%s is nil!", route_id) then local anomaly_theme = route_manager.get_route_property(route_id, "anomaly_theme") send_route_news_tip(reveal_type, 0, sender, 10, levels, anomaly_theme, play_discovery_sound) end -- TODO: Check, if still eligible to prevent log spamming at this point -- We still want to propagate the information, nonetheless. log("Revealing %s state of route #%s via news.", reveal_type, route_id) route_discovery.toggle_known_route_state(route_id, play_discovery_sound) routes_to_reveal[route_id] = nil end function queue_routes_for_propagation(selected_routes, percent_to_reveal, reveal_type) local indexed_routes = {} utils.index_keys(selected_routes, indexed_routes) -- time before next emission when all reveal messages should be posted local next_emission_in_hours = surge_manager and surge_manager.get_surge_manager and (surge_manager.get_surge_manager()._delta / (60^2)) -- game time if (not next_emission_in_hours) then next_emission_in_hours = ui_options.get("alife/event/emission_frequency") log_warn("Surge manager not available, using frequency set in options.") end local revealed = 1 local amount_to_reveal = math.floor(#indexed_routes * (percent_to_reveal/100)) for id in utils.random_numbered_sequence(1, #indexed_routes) do if (revealed > amount_to_reveal) then goto continue end revealed = revealed +1 local minutes = 6 * get_random_time(3, next_emission_in_hours * 10) local hours = math.floor(minutes / 60) minutes = minutes % 60 local days = math.floor(hours / 24) hours = hours % 24 local add_time = game.CTime() -- days +1 so we get proper result (engine quirk) add_time:set(1, 1, (days +1), hours, minutes, math.random(0, 59), 0) local selected_time = game.get_game_time() selected_time:add(add_time) log("Queued route #%s for propagation (%s)", id, reveal_type) routes_to_reveal[indexed_routes[id]] = { route = selected_routes[indexed_routes[id]], reveal_time = ctime_to_tbl(selected_time), -- don't save userdata reveal_type = reveal_type, } :: continue :: end end function find_random_stalker_on_level_pair(levels) if not utils.valid_type{ caller = "find_random_stalker_on_level_pair", "tbl", levels } then return end for id in utils.random_numbered_sequence(1, 2^16 -2) do local se_obj = alife():object(id) if not (se_obj) then goto continue end if IsStalker(nil, se_obj:clsid()) and se_obj:alive() and se_obj:community() ~= "zombied" and se_obj:community() ~= "trader" and se_obj:community() ~= "greh" and se_obj:community() ~= "isg" and se_obj:community() ~= "renegade" and se_obj.group_id ~= 65535 and (get_object_story_id(se_obj.group_id) == nil) and string.find(se_obj:name(),"sim_default_") and utils.table_has(levels, utils.get_mapname(se_obj)) then return id end :: continue :: end end function find_special_character_on_level_pair(levels) if not utils.valid_type{ caller = "find_special_character_on_level_pair", "tbl", levels } then return end local found_stalkers = {} for k, identifier in pairs(special_characters) do local id = get_story_object_id(k) local se_obj = id and alife_object(id) if not (se_obj) then goto continue end local character_map = utils.get_mapname(se_obj) local connected_maps = character_map and utils.get_directly_connected_maps(character_map) -- Route should at least be indirectly connected to the senders map if utils.table_contains(connected_maps, levels) then found_stalkers[#found_stalkers + 1] = se_obj.id end :: continue :: end return found_stalkers[math.random(1, #found_stalkers)] or find_random_stalker_on_level_pair(levels) end function send_route_news_tip(news_reveal_type, timeout, sender_id, showtime, news_levels, news_anom_theme, suppress_pda_sound) timeout = timeout or 0 showtime = showtime or 5 local npc = alife():object(sender_id) if (not npc) then return end local actor = db.actor local texture = (npc.character_icon) and npc:character_icon() or CONST_NEWS_FALLBACK_ICON local special_character_nickname = special_characters[get_object_story_id(npc.id)] local npc_story_id = special_character_nickname or npc:community() local msg_to_translate = string.format("%s_%s_%s_", CONST_STRING_ID_PREFIX, npc_story_id, news_reveal_type) local phrases = {} for i = 1, CONST_MESSAGE_MAX_STRINGS_TO_ITERATE do if (utils.has_translation(msg_to_translate .. i)) then phrases[#phrases +1] = i end end if (utils.is_table_empty(phrases)) then log_warn("Undefined string group: '%s'. Using fallback.", msg_to_translate:sub(1, -2)) msg_to_translate = CONST_STRING_ID_FALLBACK .. "_" .. news_reveal_type else msg_to_translate = msg_to_translate .. phrases[math.random(1, #phrases)] end -- Make sure $FROM is the senders level, when route connects sender map local sender_level = utils.get_mapname(npc) local index = utils.index_of(news_levels, sender_level) or math.random(1, #news_levels) local level_from, level_to = news_levels[index], news_levels[#news_levels -index +1] utils.assert(level_from and level_to) local character_name = (npc.character_name) and npc:character_name() local news_caption = character_name or game.translate_string("st_tip") -- TODO: Maybe need a debug mode flag for cases such as this news_caption = (opt.get("verbose")) and string.format("%s (%s)", news_caption, utils.get_mapname(npc)) or news_caption local anomaly_theme_string = dynamic_zone_anomalies.get_theme_string(news_anom_theme) or "" local news_text = game.translate_string(msg_to_translate) news_text = news_text:gsub("%$SENDER_NAME", character_name or CONST_MESSAGE_FALLBACK_NAME) news_text = news_text:gsub("%$FROM", game.translate_string(level_from)) news_text = news_text:gsub("%$TO", game.translate_string(level_to)) news_text = news_text:gsub("%$ANOM_THEME", game.translate_string(anomaly_theme_string)) actor:give_game_news(news_caption, news_text, texture, timeout *1000, showtime *1000, 0) if (not suppress_pda_sound) then xr_sound.set_sound_play(AC_ID, "pda_tips") -- play default sound end end