local gc = game.translate_string
local rand = math.random
local send_tip = news_manager.send_tip
local get_config = arti_lootboxes_mcm.get_config

-- test to see if scaling to 1000 helps or not
local NORMALIZE_1000 = true

-- importer for lootbox -> loot pool
ini_contents = ini_file("items\\lootboxes\\box_contents\\importer.ltx")
-- importer for loot pool -> contents
ini_pools = ini_file("items\\lootboxes\\item_pools\\importer.ltx")
ini_uniques = ini_file("items\\lootboxes\\unique_contents\\importer.ltx")
ini_lootbox = ini_file("items\\items\\items_lootbox.ltx")

ini_tools = ini_file("items\\settings\\lootbox_tools.ltx")

-- custom loaded contents
contents_custom = {}
pools_custom = {}
-- cached maps, these will get hydrated as more loot is rolled
local pool_cache = {}
local contents_cache = {}
-- map box groups to boxes
local variant_map = {}
-- track boxes that contain uniques, as well as what uniques have been seen so far
local uniques_cache = {}
-- store all lootbox contents
local lootbox_contents = {}
-- stolen from treasure_manager
local item_prop_table = { cond_r = {30,70} , cond_ct = "part" , cond_cr = {0.5,0.75,1} }

map_tiers = {}

local function save_state(mdata) 
	mdata.lootbox_contents = lootbox_contents
end

local function load_state(mdata) 
	lootbox_contents = mdata.lootbox_contents or {}
    if not lootbox_contents.uniques then
        lootbox_contents.uniques = {}
    end
end

-- unregister boxes that are deleted
local function server_entity_on_unregister(se_obj)
	if lootbox_contents[se_obj.id] then
		lootbox_contents[se_obj.id] = nil
	end
    if not lootbox_contents.uniques then
        lootbox_contents.uniques = {}
    end
    -- if a unique box is deleted, add it back to the pool
    if se_obj and lootbox_contents.uniques[se_obj.id] then
		lootbox_contents.uniques[se_obj.id] = nil
        if not uniques_cache[se_obj:section_name()] then return end
        local unique = lootbox_contents.uniques[se_obj.id]
        if unique and se_obj:section_name() then
            uniques_cache[se_obj:section_name()][unique] = true
        end
        
	end
end

function print_dbg(text, ...) 
	if get_config("debug") then
		printf("lootboxes | %s | " .. text, time_global(), ...)
	end
end

function get_loot_string(id)
    return lootbox_contents[id]
end

-- rand() against these odds
function roll(odds)
    if not odds then
        callstack()
        return false
    end
    if NORMALIZE_1000 then
        odds = math.ceil(1000*tonumber(odds))
        return rand(1000) <= odds
    else
        return rand() <= tonumber(odds)
    end
end

-- roll a cond between min and 2x min, clamped at 99
function roll_cond(min)
    return rand(min, clamp(2*min, 0, 99))
end

-- return random entry from integer-indexed table
function random_entry(t)
    t = t or {}
    return t[rand(#t)]
end

-- convert a comma-separated string of sections and numbers into a table
function parse_lootstring(str)
    local temp = str_explode(str, ",")
    local t = {}
    for i=1,#temp-1, 2 do
        table_upsert(t, temp[i], temp[i+1])
    end
    return t
end

-- convert a table into a comma-separated string of sections and numbers
function create_lootstring(template)
    str = ""
    for k,v in pairs(template) do
        str = str .. k .. "," .. v .. ","
    end
    return str
end

-- str_explode but they get parsed into numbers
function str_explode_num(str, sep, plain)
	if not (str and sep) then
		printe("!ERROR str_explode | missing parameter str = %s, sep = %s",str,sep)
		callstack()
	end
	if not (sep ~= "" and str:find(sep,1,plain)) then
		return { tonumber(str) }
	end
	local t = {}
	local size = 0
	for s in str:gsplit(sep,plain) do 
		size = size + 1
		t[size] = tonumber(trim(s))
	end
	return t
end

function table_upsert(t, k, v)
    if type(v) == "number" then
        t[k] = t[k] and t[k] + v or v
    else
        t[k] = v
    end
end

-- coerce the input into the section, or nil
local function get_sec(obj)
    if not obj then return end
    return type(obj) == "string" and obj or (type(obj.id) == "function" and obj:section() or nil)
end

local not_wpn = {
    ["WP_BINOC"] = true,
    ["WP_SCOPE"] = true,
    ["WP_SILEN"] = true,
    ["WP_GLAUN"] = true
}

function sec_is_weapon(section)
    local class = SYS_GetParam(0, section, "class")
    if string.find(section, "wpn_") then
        return not_wpn[class] == nil
    else return false
    end
end

function is_box(obj, sec)
    sec = obj and obj:section() or sec
    return SYS_GetParam(0, sec, "open_with") ~= nil
end

function is_tool(obj, sec)
    sec = obj and obj:section() or sec
    return ini_tools:section_exist(sec)
end

function init()
    local ini_map = ini_file("items\\settings\\lootbox_map_distribution.ltx")
    ini_map:section_for_each(function(map)
        if not map_tiers[map] then map_tiers[map] = {} end
        print_dbg("processing map %s", map)
        local line_count = ini_map:line_count(map) or 0
        local ref = map_tiers[map]
        for i=0,line_count-1 do
            local drop_chance = {}
            local junk1, group, chance = ini_map:r_line_ex(map, i, "", "")
            drop_chance.group = group
            drop_chance.chance = chance or 0.5
            print_dbg("adding box group %s to map %s, weight %s", group, map, chance)
            ref[i + 1] = drop_chance
        end
    end)
    local ini_stalker = ini_file("items\\settings\\lootbox_stalker_distribution.ltx")
    ini_stalker:section_for_each(function(faction)
        if not map_tiers[faction] then map_tiers[faction] = {} end
        print_dbg("processing faction %s", faction)
        local line_count = ini_stalker:line_count(faction) or 0
        local ref = map_tiers[faction]
        for i=0,line_count-1 do
            local drop_chance = {}
            local junk1, items, chance = ini_stalker:r_line_ex(faction, i, "", "")
            drop_chance.items = items
            drop_chance.chance = chance or 0.5
            ref[i + 1] = drop_chance
        end
    end)

    ini_lootbox:section_for_each(function(section)
        local group = ini_lootbox:r_string_ex(section, "box_group") or "low"
        if group then
            if not variant_map[group] then variant_map[group] = {} end
            table.insert(variant_map[group], section)
        end
    end)
    init_uniques()
end

function init_uniques()
    -- hydrate the box uniques that have not been seen so far
    -- mechanism: initially add all sections to this cache
    -- when a unique box rolls, add an entry in uniques child table associating box id to what unique section exactly
    -- when that box is opened (or deleted), drop the entry
    -- opening also marks that entry as being unable to drop again
    ini_uniques:section_for_each(function(section)
        if not lootbox_contents.uniques then lootbox_contents.uniques = {} end
        if lootbox_contents.uniques[section] then return end
        local box = ini_uniques:r_string_ex(section, "box")
        if not uniques_cache[box] then uniques_cache[box] = {} end
        uniques_cache[box][section] = true
    end)
end

-- Weapon data is stored as weapon_section,flags_ammotype_cond
function append_weapon(template, section, min, add_ammo)
    template = template or {}
    if not sec_is_weapon(section) then
        print_dbg("Section %s is not weapon! Returning only 1" , section)
        table_upsert(template, section, 1)
        return
    end
    if string.find(section, "knife") or string.find(section, "axe") then
        print_dbg("Appending melee weapon")
        table_upsert(template, section, "0_0_"..roll_cond(min))
        return
    end
    local loot_str = ""
    local flag = 0

    if (ini_sys:r_float_ex(section,"scope_status")) then
        flag = flag + 1
    end

    if (ini_sys:r_float_ex(section,"grenade_launcher_status")) then
        flag = flag + 2
    end

    if (ini_sys:r_float_ex(section,"silencer_status")) then
        flag = flag + 4
    end

    flag = rand(0,flag)

    ammos = parse_list(ini_sys,section,"ammo_class")
    ct = ammos and #ammos
    ammo_type = ammos and ct and rand(0,ct-1) or 0
    ammo_section = ammo_type and ammos[ammo_type+1]

    local condition = roll_cond(min)
    print_dbg("Appending weapon of type %s", section)
    table_upsert(template, section, flag.. "_"..ammo_type.."_"..condition)
    if add_ammo and ammos and ct and ct > 0 then 
        table_upsert(template, ammos[1], rand(3))
    end
    return template
end


-- on the chance that all maxes don't sum up, cut after this many iterations
local MAX_ITER = 10000
-- build a loot template to be substituted with items
-- loot template is table mapping item pools = quant of items
function build_template(template, section, max, bias)
    template = template or {}
    if ini_contents:section_exist(section) then
        -- cache the table
        if not contents_cache[section] then
            n = ini_contents:line_count(section)
            local pool = {}
            for i=0,n-1 do
                local content = {}
                _, id, value = ini_contents:r_line_ex(section,i,"","")
                print_dbg("Adding %s of %s", value, id)
                values = str_explode_num(value, ",")
                content.section = id
                content.size = values[1]
                content.limit = values[2]
                content.chance = values[3]
                table.insert(pool, content)
            end
            if contents_custom[section] and not is_empty(contents_custom[section]) then
                print_dbg("Adding %s custom entries for section %s", #contents_custom[section], section)
                for k,v in pairs(contents_custom[section]) do
                    table.insert(pool, v)
                end
            end
            contents_cache[section] = pool
        end
        -- build
        max = (max and max > 0) and max or 10
        bias = bias or 1
        bias = bias * get_config("lootquality")
        print_dbg("Adding %s items, bias %s", max, bias)
        content_pool = contents_cache[section]
        iters = 0
        while max > 0 do
            -- pick a random entry from the pool
            if iters > MAX_ITER then break end
            item = random_entry(content_pool)
            print_dbg("Rolled item %s, chance is %s", item.section, item.chance)
            local curr_amt = template[item.section] or 0
            if curr_amt < item.limit and item.size <= max and roll(item.chance * bias) then
                table_upsert(template, item.section, 1)
                max = clamp(max - item.size, 0, max)
                print_dbg("Added %s, (size %s), %s left", item.section, item.size, max)
            end
            iters = iters + 1
        end
    end
end

-- sub item pools for actual items in content template
function process_template(template, box_section)
    local processed = {}
    for sec,v in pairs(template) do
        for j=1,v do
            local id, value = random_item_entry(sec)
            print_dbg("Adding %s amount of %s to lootbox",value, id)
            if sec_is_weapon(id) then
                local condition = SYS_GetParam(2, box_section, "weapon_condition") or rand(20, 80)
                append_weapon(processed, id, condition)
            elseif item_device.device_npc_pda[id] then
                if not processed[id] then
                    local schmuck_id = pda_custom.cache_local_schmuck()
                    print_dbg("Adding %s's pda to the loot", schmuck_id)
                    if schmuck_id ~= 0 then
                        table_upsert(processed, id, schmuck_id)
                    end
                end
            else
                local max_multiuse = SYS_GetParam(1, box_section, "multiuse_full") or false
                local max_uses = SYS_GetParam(2, id, "max_uses") or 1
                if not max_multiuse and max_uses > 1 then
                    id = id .. "__" .. rand(max_uses)
                end
                table_upsert(processed, id, value)
            end
        end
    end
    return processed
end

-- select a random item from the item pool
function random_item_entry(section)
    print_dbg("Adding item from item pool %s", section)
    if ini_pools:section_exist(section) then
        if not pool_cache[section] then
            n = ini_pools:line_count(section)
            local contents = {}
            for i=0,n-1 do
                local ref = {}
                _, id, value = ini_pools:r_line_ex(section,i,"","")
                if not value or value == "" then value = 1 end
                id = str_explode(id, ",")
                ref.section = id[1]
                ref.amount = id[2] or 1
                ref.chance = value
                contents[#contents + 1] = ref
            end
            if pools_custom[section] and not is_empty(pools_custom[section]) then
                print_dbg("Adding %s custom entries for section %s", #pools_custom[section], section)

                for k,v in pairs(pools_custom[section]) do
                    table.insert(contents, v)
                end
            end
            pool_cache[section] = contents
            -- end
        end
        
        local item_pool = pool_cache[section]
        local selection = nil
        while not selection do
            local item = random_entry(item_pool)
            if roll(item.chance) then 
                selection = item
            end
        end
        return selection.section, selection.amount
    else
        printf("!!Content section %s not found!!", section) 
        return "duct_tape",1
    end
end

-- attempt to populate a unique. if it fails, we can provide normal contents
function try_populate_unique(box_id, box_section)
    u = uniques_cache[box_section]
    if not u or next(u) == nil then return "" end
    if rand(10) ~= 10 then return "" end
    local unique_sec = random_key_table(u)
    local unique_contents = ini_uniques:r_string_ex(unique_sec, "contents")
    print_dbg("Putting custom contents %s into box %s", unique_contents, box_section)

    if not lootbox_contents.uniques then lootbox_contents.uniques = {} end
    lootbox_contents.uniques[box_id] = unique_sec
    lootbox_contents[box_id] = unique_contents
    if (box_section == "lootbox_4") then
        lootbox_contents.spooky = box_id
    end
    
    uniques_cache[box_section][unique_sec] = nil
    return unique_contents
end

function populate_lootbox(box_id, box_section)
    if not lootbox_contents[box_id] then
        print_dbg("Populating lootbox loot for %s of type: %s",box_id,  box_section)
        local loot_type = SYS_GetParam(0,box_section,"loot_type")
        print_dbg("Loot type is %s", loot_type)
        -- populate lore box, 10% chance
        local loot_str = try_populate_unique(box_id, box_section)
        if loot_str ~= "" then
            -- no action required
        elseif loot_type == "weapon" then
            -- spawn the weapon, roll for condition + attachments + spare ammos
            local weapon_tbl = {}
            local loot_section = SYS_GetParam(0, box_section, "contents")
            local section, value = random_item_entry(loot_section, true)
            local condition = SYS_GetParam(2, box_section, "weapon_condition") or rand(20, 80)
            append_weapon(weapon_tbl, section, condition, rand(2) == 1)
            loot_str = loot_str .. create_lootstring(weapon_tbl)
        elseif loot_type == "grab" then
            local box_template = {}
            -- grab a template, iterate through the pairs of items and populate bsaed on loot
            local loot_sec = SYS_GetParam(0, box_section, "contents")
            local min, max = unpack(str_explode_num(SYS_GetParam(0, box_section, "items_range"), ","))
            local to_roll = rand(min, max)
            
            local b_min, b_max = unpack(str_explode_num(SYS_GetParam(0, box_section, "bias_range"), ",")) or 1, 2
            -- bias skews the drop rate for rare stuff up if less items spawn
            local bias = b_min + ( (b_max - b_min) * clamp(max - to_roll, 0, 999) / clamp(max - min, 1, 999) )
            build_template(box_template, loot_sec, to_roll, bias)
            local loot_template = process_template(box_template, box_section)
            loot_str = loot_str .. create_lootstring(loot_template)
        end

        lootbox_contents[box_id] = loot_str
        print_dbg("Lootbox final contents for %s: %s", box_id, loot_str)
    end
end

function actor_on_item_take_from_box(box,itm)
    local id = box:id()
    if lootbox_contents[id] == true then
        lootbox_contents[id] = nil
    end
end

function spook_player(obj)
    if lootbox_contents.spooky == obj:id() then
        send_tip(db.actor, gc("st_spooky_"..rand(4)), nil, "swiss_knife", 6000)
    end
end


function on_item_drag_dropped(from, to, slot_from, slot_to)
	if not (slot_from == EDDListType.iActorBag and slot_to == EDDListType.iActorBag) then
        return
    end
    try_open_box(to, from)
end

function check_open_compatibility(box, tool)
    box = get_sec(box)
    tool = get_sec(tool)
    local open_type = ini_tools:r_string_ex(tool, "open_type") or "nope"
    -- not a box
    local box_open_type = parse_list(ini_lootbox, box, "open_with", true)
    print_dbg("Checking tool %s (open type %s) against box %s", tool, open_type, box)
    return box_open_type[open_type]
end

-- check if actor can even open the box
-- returns a table of result and reason (reason is passed to actor)
-- preconds need the same!
function check_open_box(box, tool)
    -- coerce into section
    box_sec = get_sec(box)
    tool_sec = get_sec(tool)
    -- not a tool
    if not check_open_compatibility(box_sec, tool_sec) then 
        return {
            result = false,
            reason = "st_incompatible"}
    end
    local precond = ini_tools:r_string_ex(tool_sec, "precondition") or "arti_lootboxes.no_precond"
    precond = str_explode(precond,"%.")
    if _G[precond[1]] and _G[precond[1]][precond[2]] then
        return _G[precond[1]][precond[2]](box, tool) 
    else 
        return {
            result = false,
            reason = "st_incompatible"} 
    end
end

-- attempt to use the tool on the box and open it
function try_open_box(box, tool)
    -- tool/box validation here
    if not is_box(box) or not is_tool(tool) then return false end
    local res = check_open_box(box, tool)
    if res.result then
        open_lootbox(box, tool)
        post_open_box(box, tool)
    else
        -- tool not compatible/insufficient
        send_tip(db.actor, gc(res.reason), nil, "swiss_knife", 6000)
    end
end

-- check postcondition
function post_open_box(box, tool)
    tool_sec = get_sec(tool)
    local postcond = ini_tools:r_string_ex(tool_sec, "postcondition") or "arti_lootboxes.no_precond"
    postcond = str_explode(postcond,"%.")
    if _G[postcond[1]] and _G[postcond[1]][postcond[2]] then
        _G[postcond[1]][postcond[2]](box, tool)
    end    
end

function no_precond()
    return {
        result = true
    }
end

-- for patchers
function get_difficulty(box, tool)
    box = get_sec(box)
    tool = get_sec(tool)
    local diff = SYS_GetParam(2, box, "difficulty") or 1
    if db.actor:object("lockpick_set") then diff = clamp(diff - 1, 1, 10) end
    return diff
end

-- lockpicks, check if difficulty amount of picks are present
function precond_lockpick(box, tool)
    box_sec = get_sec(box)
    tool_sec = get_sec(tool)
    local difficulty = get_difficulty(box_sec, tool_sec)
    local amt = 0
    local function search(temp, item)
        if item:section() == tool_sec then
            amt = amt + 1
        end
    end
    db.actor:iterate_inventory(search)
    if amt == 0 then return {
        result = false,
        reason = "st_no_picks"
    } end
    amt = amt > 10 and 10 or amt
    if difficulty > amt then return {
        result = false,
        reason = "st_cant_unlock"}
    else
        return {result = true}
    end
end

-- consume X picks
function use_lockpick(box, tool)
    box_sec = get_sec(box)
    tool_sec = get_sec(tool)
    local difficulty = get_difficulty(box_sec, tool_sec)
    local amt = 0
    local function search(temp, item)
        if item:section() == tool_sec and amt < difficulty then
            alife_release_id(item:id())
            amt = amt + 1
        end
    end
    db.actor:iterate_inventory(search)
end

function use_axe(box, tool)
    -- smesh
    if (tool:condition() > 0.25) then
        tool:set_condition(clamp(tool:condition() - 0.25, 0, 1))
    else
        -- blin, axe broke
        send_tip(db.actor, gc("st_rip_axe"), nil, "swiss_knife", 6000)
        alife_release_id(tool:id())
    end
end

function precond_snapgun(box, tool)
    box_sec = get_sec(box)
    tool_sec = get_sec(tool)
    local difficulty = get_difficulty(box_sec, tool_sec)
    local power = ini_tools:r_float_ex(tool_sec, "open_power")
    if difficulty > power then
        return {
            result = false,
            reason = "st_cant_unlock_snapgun"
        }
    else
        return {
            result = true
        }
    end
end

function release_tool(box, tool)
    alife_release_id(tool:id())
end

function str_pick(box)
    local tool = db.actor:object("lockpick")
    if tool and check_open_compatibility(box, "lockpick") then
        return "st_unlock_pick"
    end
end

function do_pick(box)
    try_open_box(box, db.actor:object("lockpick"))
end

function str_snap(box)
    local tool = db.actor:object("snapgun")
    if tool and check_open_compatibility(box, tool) then
        return "st_unlock_snap"
    end
end

function do_snap(box)
    try_open_box(box, db.actor:object("snapgun"))
end

function str_coin(box)
    local tool = db.actor:object("arcade_tokens")
    if tool and check_open_box(box, tool).result then
        return "st_unlock_coin"
    end
end

function do_coin(box)
    try_open_box(box, db.actor:object("arcade_tokens"))
end


-- Weapon data is stored as weapon_section,flags_ammotype_cond
function give_weapon(weapon, attachment_data)
    if weapon == "wpn_toz194" then weapon = "wpn_wincheaster1300" end
    local table = str_explode(attachment_data, "_")
    local se_obj = alife_create(weapon,db.actor:position(),db.actor:level_vertex_id(),db.actor:game_vertex_id(),db.actor:id(),false)
    local data = utils_stpk.get_weapon_data(se_obj)
    local cond = tonumber(table[3]) or 75
    if (data) then
        data.condition = (cond/100)
        data.addon_flags = tonumber(table[1])
        data.ammo_type = tonumber(table[2])
        utils_stpk.set_weapon_data(data, se_obj)
    end
    alife():register(se_obj)
    print_dbg("Granting weapon of type %s", weapon)
    local name = SYS_GetParam(0, weapon, "inv_name")
    local quality = clamp(math.floor(cond / 25), 0, 3)
    local message =  gc("st_weapon_"..quality).." ".. gc(name) .. "\\n"
    -- send_tip(db.actor, message, nil, "swiss_knife", 6000)
    return message

end

function open_lootbox(box, tool)
	local id = box:id()
	if not (lootbox_contents[id]) then
        print_dbg("Lootbox contents not found. Populating.")
		populate_lootbox(id, box:section())
	end
    -- play cool animation, with cool sound
    if get_config("animation") then
        tool_sec = get_sec(tool)
        local sound = ini_tools:r_string_ex(tool_sec, "sound") or "Plastic_Small_Pick"
        local delay = ini_tools:r_float_ex(tool_sec, "duration") or 5

        CreateTimeEvent("lootbox", "disable_ui", 0, function()
            xr_effects.disable_ui_only(db.actor, nil)
            return true
        end)        
        utils_obj.play_sound(sound)
        print_dbg("Start open box with id "..id)
        RemoveTimeEvent("lootbox","box_open " .. id)
        CreateTimeEvent("lootbox","box_open " .. id, delay, 
        function(id, give)        
            xr_effects.enable_ui_lite(db.actor, nil)
            open_lootbox_timer(id, give)
            return true
        end, id, true)
    else
        RemoveTimeEvent("lootbox","box_open " .. id)
        CreateTimeEvent("lootbox","box_open " .. id, 0.1, 
        function(id, give)
            open_lootbox_timer(id, give)
            return true
        end, id, true)
    end

end

function open_lootbox_timer(id, give)
    if not lootbox_contents[id] then return false end
    print_dbg("Opening box with id "..id)
	local spawned_items = parse_lootstring(lootbox_contents[id])
    local str = gc("st_lootbox_get") .. ":\\n"
    -- give loot
    for section, quantity in pairs(spawned_items) do
        print_dbg("Creating %s of %s", quantity, section)
        if section == "spooky" then
            alife_create("m_poltergeist_normal_flame", db.actor:position(), db.actor:level_vertex_id(), db.actor:game_vertex_id())
            send_tip(db.actor, gc("st_spooky_free"), nil, "swiss_knife", 6000)
            lootbox_contents.spooky = nil
        elseif section == "money" then
            local money_amt = tonumber(quantity) or 5000
            db.actor:give_money(money_amt)
           str = str ..money_amt .. " ".. gc("st_money") .. "\\n"
        elseif sec_is_weapon(section) then
            str = str .. give_weapon(section, quantity)
        elseif item_device.device_npc_pda[section] then
            local se_itm = alife_create_item(section, db.actor, item_prop_table)
            local id = se_itm and se_itm.id
            -- quantity will be the id of the schmuck this pda belongs to
            pda_custom.register_pda(tonumber(quantity), section, id)
        else
            if section == "leatherman" then section = "leatherman_tool" end
            local amt = tonumber(quantity) or 1
            for j=1,amt do
                local se_itm = alife_create_item(section, db.actor, item_prop_table)
            end
            if string.find(section, "__") then section = str_explode(section, "__")[1] end
            local item_name = ui_item.get_sec_name(section) or "of something"
            if string.find(section, "ammo") then
                local str_ammos = amt > 1 and "st_loot_ammos" or "st_loot_ammo"
                str = str..  amt.. " ".. gc(str_ammos) .. " "..gc(item_name).. "\\n"
                
            else
                str = str..  amt.. " "..gc(item_name).. "\\n"
            end
        end
    end
    send_tip(db.actor, str, nil, "swiss_knife", 6000)
    if give then
        lootbox_contents[id] = nil
        -- manage uniques - once player opens, remove unique from appearing again    
        if not lootbox_contents.uniques then lootbox_contents.uniques = {} end
        if lootbox_contents.uniques[id] then
            local unique_sec = lootbox_contents.uniques[id]
            lootbox_contents.uniques[id] = nil
            lootbox_contents.uniques[unique_sec] = true
            print_dbg("Lootbox unique %s opened", unique_sec)
        end
        lootbox_contents[id] = nil
        alife_release_id(id)
    end
end

-- given a group id, return a lootbox section from that group, if applicable
function pick_lootbox(map)

    if map_tiers[map] then
        local group = nil
        local bias = get_config("lootquality") or 1
        while not group do
            local m = random_entry(map_tiers[map])
            if roll(m.chance * bias) then
                group = m.group
            end
        end
        return random_entry(variant_map[group])
    end
end

-- spawn lootbox in box, tie the contents of box to creation time to prevent RNG cheese
function spawn_lootbox(box)
    local id = box:id()
    if lootbox_contents[id] then return end
    local roll = rand(100)
    if roll <= get_config("stashchance") then
        local se_obj = alife_object(id) 
        local lvl = alife():level_name(game_graph():vertex(se_obj.m_game_vertex_id):level_id())
        print_dbg("spawning box in %s", lvl)
        local box_type = pick_lootbox(lvl)

        if ini_sys:section_exist(box_type) then
            print_dbg("Spawned in lootbox of type "..box_type)
            se_itm = alife_create_item(box_type, box)
            -- populate_lootbox(se_itm.id, "lootbox_"..box_type)
        else
            print_dbg("Could not spawn lootbox of type "..box_type)
        end
    end
    if roll <= 10 then
        local section = "lockpick"
        local roll = rand(20)
        -- 70% one pick, 25% bundle, 5% skelekey 
        if roll == 20 then
            section = "skeleton_key"
        -- elseif roll > 16 then
        --     section = "bundle_lockpick"
        else
            section = "lockpick"
        end
        local se_obj = alife_create(section,box:position(),box:level_vertex_id(),box:game_vertex_id(),box:id(),false)
        alife():register(se_obj)
    end
    -- set box id to true to mark that is has been checked
    lootbox_contents[id] = true
end

-- spawn items on dead people
function spawn_stalker_loot(npc)

    if rand(100) <= get_config("deathchance") then
        local community = npc:character_community()
        local rank_bias = (npc:rank() / 10000) or 1
        if map_tiers[community] == nil then community = "stalker" end
        local table = map_tiers[community]
        local to_drop = nil
        local iters = 0
        while not to_drop do
            local m = random_entry(map_tiers[community])
            if not m.chance then m.chance = 0.5 end
            if roll(m.chance * rank_bias) then
                to_drop = m.items
            end
        end
        tbl = str_explode(to_drop, ",")
        if not tbl[2] then tbl[2] = 1 end
        print_dbg("Spawning %s of %s on dead NPC", tbl[2], tbl[1])
        for i=1,tonumber(tbl[2]) do
            se_obj = alife_create(tbl[1],  npc:position(), npc:level_vertex_id(), npc:game_vertex_id(), npc:id(), false)
            alife():register(se_obj)
        end
    end
end

-- monkey patch loot managers
SpawnTreasure = treasure_manager.try_spawn_treasure

function treasure_manager.try_spawn_treasure(box)
    local id = box:id()

	--printf("try_spawn_treasure [%s]",caches[id])
    -- no spawn if the cache is already looted
	if not (treasure_manager.caches[id]) then
		return
	end
    spawn_lootbox(box)
    SpawnTreasure(box)
end

-- string stuff

function build_diff(box_sec)
    local difficulty = get_difficulty(box_sec) * 10
    local clr = utils_xml.get_color_con(100 - tonumber(difficulty))
    return clr .. " " .. gc("st_dot").. " " .. utils_xml.get_color("ui_gray_1") .. gc("st_box_difficulty") .. " " .. clr .. tostring(difficulty) .. "%" .. "\\n \\n" .. utils_xml.get_color("ui_gray_1")   
end

function build_coins(box_sec)
    local clr = utils_xml.get_color_con(0)
    return clr .. " " .. gc("st_dot").. " " .. utils_xml.get_color("ui_gray_1") .. gc("st_box_difficulty") .. " " .. clr .. "??????" .. "\\n \\n" .. utils_xml.get_color("ui_gray_1")
end

function build_monoloot(box_sec)
    local clr = utils_xml.get_color_con(0)
    return clr .. " " .. gc("st_dot").. " " .. utils_xml.get_color("ui_gray_1") .. gc("st_box_difficulty") .. " " .. clr  .. "9001%" .. "\\n \\n" .. utils_xml.get_color("ui_gray_1")  
end

-- get custom display of difficulty
function build_string(box_sec)
    local precond = SYS_GetParam(0, box_sec, "diff_str") or "arti_lootboxes.build_diff"
    precond = str_explode(precond,"%.")
    if _G[precond[1]] and _G[precond[1]][precond[2]] then
        return _G[precond[1]][precond[2]](box_sec) 
    else return "" end
end


BuildFooter = ui_item.build_desc_footer
function ui_item.build_desc_footer(obj, sec, str)
    str = str or gc(ini_sys:r_string_ex(sec,"description"))
	if (not str) then return "" end
    if is_box(nil, sec) then
        return str .. build_string(sec)
    else
        return BuildFooter(obj, sec, str)
    end
end

CreateReleaseItem = death_manager.create_release_item
function death_manager.create_release_item(npc)
    CreateReleaseItem(npc)
    spawn_stalker_loot(npc)
end


function on_game_start()
	if (USE_MARSHAL) then 
		RegisterScriptCallback("save_state",save_state)
		RegisterScriptCallback("load_state",load_state)
	end
	RegisterScriptCallback("actor_on_item_take_from_box",actor_on_item_take_from_box)
    RegisterScriptCallback("ActorMenu_on_item_drag_drop",on_item_drag_dropped)
	RegisterScriptCallback("server_entity_on_unregister", server_entity_on_unregister)
	RegisterScriptCallback("actor_on_item_take",spook_player)
    init()
end