--[[ Jabbers 05APR2023 Jabbers' Soulslike Anomaly Mod --]] local version = "0.28-beta" local tools_list = { ["itm_basickit"] = true, ["itm_advancedkit"] = true, ["itm_expertkit"] = true, ["itm_drugkit"] = true, ["itm_ammokit"] = true, ["itm_gunsmith_toolkit"] = true, ["itm_artefactskit"] = true, } local scenario_logic = nil RELATIONS = { FRIENDS = 1000, BUDDIES = 500, NEUTRALS = 0, ENEMIES = -1000 } SCENARIOS = { Default = 1, RFDetectorStash = 2, HiddenStash = 3, NoLoss = 4, } hit_type_to_str = { [hit.light_burn] = "Light Burn", [hit.burn] = "Burn", [hit.strike] = "Strike", [hit.shock] = "Shock", [hit.wound] = "Wound", [hit.radiation] = "Radiation", [hit.telepatic] = "Telepatic", [hit.chemical_burn] = "Chemical Burn", [hit.explosion] = "Explosion", [hit.fire_wound] = "Fire", } entity_type = { Stalker = 1, Monster = 2, Anomaly = 3, Self = 4, Other = 4 } ---------------------------------------- -- Classes ---------------------------------------- MAX_HIT_POOL_COUNT = 30 MAX_HIT_TIME = 30000 local hit_queue = soulslike_classes.TimedQueue(MAX_HIT_POOL_COUNT, MAX_HIT_TIME) ---------------------------------------- -- Helpers ---------------------------------------- function try(func, ...) local status, error_or_result = pcall(func, ...) if not status then soulslike.error(error_or_result) return false else return error_or_result end end function math.clamp(x, min, max) if x < min then return min end if x > max then return max end return x end function table.get_length(T) local count = 0 for _ in pairs(T) do count = count + 1 end return count end function table.has_value(tab, val) for _, value in ipairs(tab) do if value == val then return true end end return false end ---------------------------------------- -- DEBUG ---------------------------------------- local function print_table (tbl, indent) if not indent then indent = 0 end utils_data.debug_write(string.rep(" ", indent) .. "{") indent = indent + 2 if (type(tbl) == "userdata") then utils_data.debug_write(",\n") else for k, v in pairs(tbl) do local toprint = string.rep(" ", indent) if (type(k) == "number") then toprint = toprint .. "[" .. k .. "] = " elseif (type(k) == "string") then toprint = toprint .. k .. " = " end if (type(v) == "number") then utils_data.debug_write(toprint .. v .. ",") elseif (type(v) == "string") then utils_data.debug_write(toprint .. "\"" .. v .. "\",") elseif (type(v) == "table") then utils_data.debug_write(toprint) print_table(v, indent + 2) else utils_data.debug_write(toprint .. "\"" .. tostring(v) .. "\",") end end end utils_data.debug_write(string.rep(" ", indent-2) .. "}") end local function log(log_type, output) if not output then utils_data.debug_write("[Soulslike] "..log_type..": ".."(nil)") elseif (type(output) == "table") then utils_data.debug_write("[Soulslike] "..log_type..": ") print_table(output) else utils_data.debug_write("[Soulslike] "..log_type..": "..output) end end function debug(output) log("DEBUG", output) end function info(output) log("INFO ", output) end function warn(output) log("WARN ", output) end function error(output) log("ERROR", output) end function debug_tip(text, delay) debug(text) if not soulslike_mcm.show_debug_tips() then return end local text = "[Soulslike] "..tostring(text) if not db.actor then return end local ico = "ui_inGame2_Dengi_otdani" local text_color = utils_xml.get_color("pda_white") text = text_color .. text if delay == nil then delay = 6000 end news_manager.send_tip(db.actor, text, nil, ico, delay) end ---------------------------------------- -- Globals ---------------------------------------- function _G.IsSoulslikeMode() return not IsHardcoreMode() and axr_main.config and axr_main.config:r_value("character_creation","new_game_soulslike_mode",1) == true or alife_storage_manager.get_state().enable_soulslike_mode == true end function _G.IsToolkit(o, s) if not (s) then s = o and o:section() end return tools_list[s] end ---------------------------------------- -- Helper Functions ---------------------------------------- function get_soulslike_state() local game_state = alife_storage_manager.get_state() if not game_state.soulslike then game_state.soulslike = { created_stashes = {}, spawn_location = { level = nil, position = { x = nil, y = nil, z = nil, }, angle = { x = nil, y = nil, z = nil, }, level_vertex_id = nil, game_vertex_id = nil, }, note_message_data = {}, hidden_stashes = {} } end -- Backward save compatibility if not game_state.soulslike.note_message_data then game_state.soulslike.note_message_data = {} end if not game_state.soulslike.hidden_stashes then game_state.soulslike.hidden_stashes = {} end if not game_state.soulslike.created_stashes then game_state.soulslike.created_stashes = {} end if not game_state.soulslike.spawn_location then game_state.soulslike.spawn_location = { level = nil, position = { x = nil, y = nil, z = nil, }, angle = { x = nil, y = nil, z = nil, }, level_vertex_id = nil, game_vertex_id = nil, } end return game_state.soulslike end function set_spawn(show_message) local se_actor = alife():actor() local state = get_soulslike_state() state.spawn_location.level = level.name() state.spawn_location.position.x = se_actor.position.x state.spawn_location.position.y = se_actor.position.y state.spawn_location.position.z = se_actor.position.z state.spawn_location.angle.x = se_actor.angle.x state.spawn_location.angle.y = se_actor.angle.y state.spawn_location.angle.z = se_actor.angle.z state.spawn_location.level_vertex_id = se_actor.m_level_vertex_id state.spawn_location.game_vertex_id = se_actor.m_game_vertex_id debug("Saved spawn location data:") if show_message then local str = game.translate_string("st_soulslike_spawn_location_set") actor_menu.set_msg(1, str, 4) end end function find_closest_enemy() local enemy = nil local enemy_dist = 150 local sim = alife() local gg = game_graph() local level_name = level.name() if not sim then return end if not gg then return end for i=1,65534 do local se_obj = sim:object(i) if se_obj and (level_name == sim:level_name(gg:vertex(se_obj.m_game_vertex_id):level_id())) then local cls = se_obj:clsid() local sec = se_obj:section_name() local is_valid = false if IsStalker(nil,cls) and string.find(sec,"sim_default_") and se_obj:alive() then local comm = se_obj:community() if (comm ~= "trader") and (comm ~= "zombied") then is_valid = true end elseif IsMonster(nil,cls) then is_valid = true end if is_valid then local dist = se_obj.position:distance_to_sqr(db.actor:position()) if enemy_dist > dist then if IsStalker(nil,cls) then local comm = se_obj:community() debug("Found enemy "..se_obj:name().." from the "..comm.." community "..tostring(dist).." meters away.") enemy = se_obj elseif IsMonster(nil,cls) then enemy = se_obj end end end end end return enemy end function find_closest_enemy_mutant() local enemy = nil local enemy_dist = 75 local sim = alife() local gg = game_graph() local level_name = level.name() if not sim then return end if not gg then return end for i=1,65534 do local se_obj = sim:object(i) if se_obj and (level_name == sim:level_name(gg:vertex(se_obj.m_game_vertex_id):level_id())) then local cls = se_obj:clsid() if IsMonster(nil,cls) then local dist = se_obj.position:distance_to_sqr(db.actor:position()) if enemy_dist > dist then if IsMonster(nil,cls) then enemy = se_obj end end end end end return enemy end function find_closest_enemy_stalker() local friend = nil local friend_dist = 100000 for i=1, #db.OnlineStalkers do local id = db.OnlineStalkers[i] local npc = db.storage[id] and db.storage[id].object or level.object_by_id(id) if npc then local dist = npc:position():distance_to_sqr(db.actor:position()) local is_friend = npc:general_goodwill(db.actor) <= RELATIONS.ENEMIES if npc:alive() and is_friend and friend_dist > dist and (not get_object_story_id(id)) then local comm = npc:character_community() debug("Found friend "..npc:name().." from the "..comm.." community "..tostring(dist).." meters away.") friend = npc friend_dist = dist end end end return friend end function find_closest_friendly_stalker() local friend = nil local friend_dist = 100000 for i=1, #db.OnlineStalkers do local id = db.OnlineStalkers[i] local npc = db.storage[id] and db.storage[id].object or level.object_by_id(id) if npc then local dist = npc:position():distance_to_sqr(db.actor:position()) local is_friend = npc:general_goodwill(db.actor) >= RELATIONS.ENEMIES if npc:alive() and is_friend and friend_dist > dist and (not get_object_story_id(id)) then local comm = npc:character_community() debug("Found friend "..npc:name().." from the "..comm.." community "..tostring(dist).." meters away.") friend = npc friend_dist = dist end end end return friend end function force_save(type) --if game isn't already paused, then force a pause here local force_pause if not (device():is_paused()) then device():pause(true) force_pause = true end local Y, M, D, h Y, M, D, h = game.get_game_time():get(Y, M, D, h) local m = level.get_time_minutes() if m < 10 then m = ("0"..m) end local comm = utils_xml.get_special_txt(db.actor:character_community()) local map = utils_xml.get_special_txt(level.name()) local date = string.format("%d.%d.%d %d-%d", D, M, Y, h, m) local file_name = "soulslike_"..comm.." - "..map.." "..date.." - "..type exec_console_cmd("save ".. file_name) if (force_pause) then device():pause(false) end end ---------------------------------------- -- Dream Callbacks ---------------------------------------- function wakeup_callback() debug("wakeup_callback") xr_effects.enable_ui(db.actor, nil) exec_console_cmd("snd_volume_music "..tostring(_G.mus_vol)) exec_console_cmd("snd_volume_eff "..tostring(_G.amb_vol)) _G.amb_vol = 0 _G.mus_vol = 0 disable_info("tutorial_sleep") disable_info("actor_is_sleeping") disable_info("sleep_active") debug("Looking for scenario logic.") if scenario_logic then debug("Completing scenario.") scenario_logic:OnComplete() else error("No logic state") end local data = get_soulslike_state() scenario_logic:destroy() scenario_logic = nil data.logic_state = nil end function dream_callback() debug("dream_callback") level.add_cam_effector("camera_effects\\sleep.anm", 10, false, "soulslike.wakeup_callback") local hours = math.random(6,14) level.change_game_time(0,hours,0) db.actor.power = 1 SendScriptCallback("actor_on_sleep", hours) end ---------------------------------------- -- Game Callbacks ---------------------------------------- local function actor_on_before_death(who, flags) if not IsSoulslikeMode() then return end -- Pretty sure this fixes arena fights, still need to test if has_alife_info("bar_arena_fight") then debug('Actor was in an arena fight, ignoring death.') return end debug('Actor died') game_statistics.increment_statistic("deaths") hit_queue:Invalidate() scenario_logic = soulslike_scenario_logic_factory.create_new(hit_queue:Values()) if scenario_logic then scenario_logic:OnDeath() flags.ret_value = false end hit_queue:Clear() end local function on_before_save_input(flags, type, text) if not IsSoulslikeMode() then return end -- No hardcore save setting, allow saving if not soulslike_mcm.is_hardcore_save_enabled() then return end -- Hardcore save is enabled, but we still want to save at campfires -- We just return to let the regular saving work. if soulslike_mcm.override_campfire_hardcore_saves() then return end -- All other scenarios flow through here and we just disallow saving if not level_weathers.valid_levels[level.name()] then return end debug('User tried to save') local str = game.translate_string("st_save_only_when_sleeping") actor_menu.set_msg(1, str, 4) exec_console_cmd("main_menu off") flags.ret = true end local function try_send_inventory_examined_message(stash_id) local data = get_soulslike_state() local stash_data = data.created_stashes[stash_id] if stash_data and not stash_data.examine then local lost_items = stash_data.lost_items debug('Stash not yet examined.') debug(lost_items) if #lost_items > 0 then local msg = "You examine your belongings and find that you were missing the following items: " local item_groups = {} for _, sec in pairs(lost_items) do if not item_groups[sec] then item_groups[sec] = { section = sec, count = 1 } else item_groups[sec].count = item_groups[sec].count + 1 end end local item_names = {} for _, group in pairs(item_groups) do local inv_name = ui_item.get_sec_name(group.section) if group.count > 1 then table.insert(item_names, tostring(group.count).." x "..inv_name) else table.insert(item_names, inv_name) end end msg = msg..table.concat(item_names, ", ") local ui_sender = news_manager.tips_icons['default'] db.actor:give_game_news("", msg, ui_sender, 0, 20000) stash_data.examine = true end end end local function actor_on_item_take_from_box(box,obj) if not IsSoulslikeMode() then return end local state = get_soulslike_state() local id = box:id() if (box:section() == "inv_backpack") then if (box:is_inv_box_empty()) then hide_hud_inventory() local se_obj = alife_object(id) if se_obj then alife_release(se_obj) try_send_inventory_examined_message(id) state.created_stashes[id] = nil end end end end local function actor_on_stash_remove(data) if not IsSoulslikeMode() then return end local state = get_soulslike_state() if state.created_stashes[data.stash_id] then data.cancel = true try_send_inventory_examined_message(state.stash_id) end end local function on_console_execute(name, ...) if not IsSoulslikeMode() then return end if(name == "save") then debug(name) local extraArgs = {...} if extraArgs then local last_save_file_name = table.concat(extraArgs," ") debug(last_save_file_name) if soulslike_mcm.is_hardcore_save_enabled() then last_save_file_name = string.lower(last_save_file_name) debug("Don't delete: ".. last_save_file_name) local uuid = get_soulslike_state().uuid local fs = getFS() if not fs then return end local flist = fs:file_list_open_ex("$game_saves$",bit_or(FS.FS_ListFiles,FS.FS_RootOnly),"*.scoc") local f_cnt = flist:Size() for it=0, f_cnt-1 do local file = flist:GetAt(it) local file_name = string.sub(file:NameFull(), 0, (string.len(file:NameFull()) - string.len(".scoc"))) local scoc_path = fs:update_path('$game_saves$', '')..file_name..".scoc" local scop_path = fs:update_path('$game_saves$', '')..file_name..".scop" local dds_path = fs:update_path('$game_saves$', '')..file_name..".dds" local f = io.open(scoc_path,"rb") if f then local data = f:read("*all") f:close() if (data) then local decoded = alife_storage_manager.decode(data) local d_soulslike = decoded and decoded.soulslike if (d_soulslike and (d_soulslike.uuid == uuid)) then debug("/ Soulslike mode | file: "..file_name) file_name = string.lower(file_name) if file_name ~= last_save_file_name then debug("~ Soulslike mode | delete save file: "..file_name) local scoc_path_bak = fs:update_path('$game_saves$', '').."soulslike-backup/"..file_name..".scoc" local scop_path_bak = fs:update_path('$game_saves$', '').."soulslike-backup/"..file_name..".scop" local dds_path_bak = fs:update_path('$game_saves$', '').."soulslike-backup/"..file_name..".dds" fs:file_copy(scoc_path, scoc_path_bak) fs:file_copy(scop_path, scop_path_bak) fs:file_copy(dds_path, dds_path_bak) ui_load_dialog.delete_save_game(file_name) end end end end end end end end end local function physic_object_on_use_callback(box, who) local data = get_soulslike_state() if not IsInvbox(box) or not data.hidden_stashes or not data.hidden_stashes[box:id()] then return end local id = box:id() local stash_data = data.hidden_stashes[id] local stash_id = stash_data.stash_id local se_obj = alife_object(stash_id) local stash = level.object_by_id(stash_id) if not stash then warn("Not expected, unable to find stash id linked to "..tostring(id)..". Please save and reload near the stash to see if it solves the issue.") return end local sim = alife(); if not sim then return end if se_obj and not se_obj.online then se_obj:switch_online() end sim:set_switch_online(stash_id,true) sim:set_switch_offline(stash_id,false) try_send_inventory_examined_message(stash_id) local function transfer_item(temp,item) debug("Transfering item "..item:section()) stash:transfer_item(item, box) end if stash_data.radio_id then debug("Clearing radio stash "..tostring(stash_data.radio_id)) item_radio.clear_stash(stash_data.radio_level, stash_data.radio_id) end debug("Transfering items from hidden stash "..tostring(id).." to static stash "..tostring(stash_id)) stash:iterate_inventory_box(transfer_item) alife_release(stash) debug("Removing PDA marker "..tostring(id)) level.map_remove_object_spot(id , "secondary_task_location") data.hidden_stashes[id] = nil end local function load_state(data) if not IsSoulslikeMode() then return end local data = get_soulslike_state() if not data.logic_state then debug("No logic state") elseif data.logic_state and not data.logic_state.scenario_id then warn("Logic state exists without scenario id") elseif data.logic_state and data.logic_state.scenario_id then -- Reinitialize the last scenario using the saved logic state. scenario_logic = soulslike_scenario_logic_factory.create_by_id(data.logic_state.scenario_id, data.logic_state) end end local function save_state(data) if not IsSoulslikeMode() then return end local data = get_soulslike_state() if not data.spawn_location.level and level.name() ~= 'fake_start' then set_spawn(false) end if scenario_logic then -- Save the logic state for the current scenario so we can restore it on load -- This is for the purposes of ChangeLevel which unloads the scripts and then -- reloads them, so we need to be able to reinitialize the scenario as it was -- before changing levels data.logic_state = scenario_logic.logic_state; end end local function on_level_changing() if not IsSoulslikeMode() then return end debug("On on_level_changing load.") local data = get_soulslike_state() if not data.spawn_location.level and level.name() ~= 'fake_start' then set_spawn(false) end end local function on_game_load() if not IsSoulslikeMode() then return end debug("On game load.") local data = get_soulslike_state() local sim = alife() if sim then debug("Updating position and status of hidden stashes.") for box_id, value in pairs(data.hidden_stashes) do local se_hidden_stash = alife_object(value.stash_id) local se_box = alife_object(box_id) if not se_hidden_stash then debug("Unable to find hidden stash "..tostring(value.stash_id)) elseif not se_box then debug("Unable to find box id "..tostring(box_id)) else debug("Moving hidden stash"..tostring(value.stash_id)) sim:teleport_object(value.stash_id, se_box.m_game_vertex_id, se_box.m_level_vertex_id, se_box.position) debug("Setting hidden stash online") if not se_box.online then se_box:switch_online() end for i=1,65534 do local se_obj = sim:object(i) if (se_obj and se_obj.parent_id == id) then debug("Moving "..se_obj.section.." id:"..tostring(value.stash_id)) sim:teleport_object(value.stash_id, se_box.m_game_vertex_id, se_box.m_level_vertex_id, se_box.position) if se_obj then if not se_obj.online then debug("Setting "..se_obj.section.." online") se_obj:switch_online() end end end end end end end -- Write out an identifier so we can use it later -- to identify other saves with the same ID if the -- user is playing with Hardcore Saves enabled. if not data.uuid then data.uuid = GAME_VERSION .. "_" .. tostring(math.random(100)) .. tostring(math.random()) .. tostring(math.random(1000)) end debug("Looking for scenario logic.") if scenario_logic then debug("Calling scenario respawn.") scenario_logic:OnRespawn() end end local function actor_on_sleep(hours) if not IsSoulslikeMode() then return end soulslike.debug('actor_on_sleep') -- Only force save if we aren't running a scenario if not scenario_logic then soulslike.force_save("sleep") end end local detour_actor_on_before_hit = nil local function actor_on_before_hit(shit, bone_id, flags) if not IsSoulslikeMode() then if detour_actor_on_before_hit then detour_actor_on_before_hit(shit, bone_id, flags) end return end local damage = shit.power if grok_actor_damage_balancer and grok_actor_damage_balancer.damage then --debug("Using damage from Grok's Damage Balancer") damage = grok_actor_damage_balancer.damage end --debug("Player hit:") --debug("shit.type: "..tostring(hit_type_to_str[shit.type])) --debug("shit.power: "..tostring(damage)) --debug("shit.impulse: "..tostring(shit.impulse)) --debug("shit.draftsman: "..(shit and shit.draftsman:name() or "")) if shit.draftsman then --debug("shit.draftsman: "..(shit and shit.draftsman:name() or "")) end local health = db.actor.health local is_fatal = shit.power >= health hit_queue:Enqueue({ type = shit.type, power = damage, is_fatal = is_fatal, time = time_global(), draftsman_id = shit.draftsman and shit.draftsman:id() or nil, }) --debug("is_fatal: "..tostring(is_fatal)) end local function npc_on_net_spawn(npc, se_obj) if not IsSoulslikeMode() then return end local state = get_soulslike_state() if npc and IsStalker(npc) and state.tracked_ambushers and state.tracked_ambushers[npc:id()] then debug("Setting up ambushers "..tostring(npc:id())) local ambusher_state = state.tracked_ambushers[npc:id()] local position = vector():set(ambusher_state.position.x, ambusher_state.position.y, ambusher_state.position.z) npc:set_mental_state(ambusher_state.mental_state) npc:set_body_state(ambusher_state.body_state) npc:set_movement_type(ambusher_state.movement_type) npc:set_sight(ambusher_state.sight_type, nil, 0) npc:set_desired_position(position) npc:set_desired_direction() if soulslike_mcm.debug_squad_spawns() then level.map_add_object_spot_ser(npc:id(), "secondary_task_location", "DEBUG: Ambush NPC") end state.tracked_ambushers[npc:id()] = nil end end function on_game_start() debug('Version: '..version) RegisterScriptCallback("actor_on_stash_remove", actor_on_stash_remove) RegisterScriptCallback("actor_on_item_take_from_box", actor_on_item_take_from_box) RegisterScriptCallback("actor_on_before_death", actor_on_before_death) RegisterScriptCallback("on_before_save_input", on_before_save_input) RegisterScriptCallback("save_state", save_state) RegisterScriptCallback("load_state", load_state) RegisterScriptCallback("on_level_changing", on_level_changing) RegisterScriptCallback("on_game_load", on_game_load) RegisterScriptCallback("actor_on_sleep", actor_on_sleep) RegisterScriptCallback("on_console_execute", on_console_execute) RegisterScriptCallback("actor_on_before_hit", actor_on_before_hit) RegisterScriptCallback("physic_object_on_use_callback", physic_object_on_use_callback) --RegisterScriptCallback("npc_on_net_spawn", npc_on_net_spawn) end