340 lines
14 KiB
Plaintext
340 lines
14 KiB
Plaintext
|
--[[
|
||
|
DYNAMIC ZONE - PDA News
|
||
|
|
||
|
Original Author(s)
|
||
|
Singustromo <singustromo at disroot.org>
|
||
|
VodoXleb <vodoxlebushek>
|
||
|
|
||
|
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
|