-- RennStats 1.0 | headless inventory collector and website-controlled trade. -- Backup: lua/backups/renn-inventory-before-rennstats-20261004.lua -- Configure through getgenv().RENNSTATS_CONFIG. See RennStats/README.md. local Core = (function() local M = {} local idFields = { "Id", "ID", "ItemId", "ItemID", "id", "itemId" } local nameFields = { "Name", "name", "ItemName", "DisplayName" } local iconFields = { "Icon", "icon", "Image", "ImageId", "ImageID", "IconId", "IconID", "IconAssetId", "TextureId", "Thumbnail" } local qtyFields = { "Quantity", "quantity", "Amount", "amount", "Count", "count", "Qty", "qty", "Stack" } local uniqueFields = { "UUID", "Uuid", "uuid", "Uid", "UID", "UniqueId" } local tradeFields = {"Tradable", "Tradeable", "IsTradable", "IsTradeable", "CanTrade", "RAP", "Rap", "rap", "RecentAveragePrice"} local function tradeData(value) local result = {} for _, source in ipairs({type(value.TradeData) == "table" and value.TradeData or {}, value, type(value.Data) == "table" and value.Data or {}, type(value.Metadata) == "table" and value.Metadata or {}, type(value.Meta) == "table" and value.Meta or {}}) do for _, field in ipairs(tradeFields) do if result[field] == nil and (type(source[field]) == "number" or type(source[field]) == "boolean") then result[field] = source[field] end end end return result end local secondaryEnchantFields = {"SecondEnchantments", "SecondEnchantId", "SecondEnchantmentId", "SecondEnchant", "SecondEnchantment", "EnchantId2", "EnchantmentId2", "Enchant2", "Enchantment2", "SecondaryEnchant", "SecondaryEnchantId"} local mutationFields = {"Variant", "variant", "VariantId", "variantId", "VariantIds", "variantIds", "Mutation", "mutation", "MutationId", "mutationId", "Mutations", "mutations", "MutationIds", "mutationIds"} local function clean(value) return tostring(value or ""):match("^%s*(.-)%s*$") or "" end local function normalized(value) return clean(value):lower():gsub("[%s_%-]", "") end local aliases = { fish = "Fish", fishes = "Fish", fishingrod = "Fishing Rods", fishingrods = "Fishing Rods", rod = "Fishing Rods", rods = "Fishing Rods", bait = "Baits", baits = "Baits", gear = "Gears", gears = "Gears", enchantstone = "Enchant Stones", enchantstones = "Enchant Stones", pet = "Pets", pets = "Pets", petegg = "Pet Eggs", peteggs = "Pet Eggs", boat = "Boats", boats = "Boats", lantern = "Lanterns", lanterns = "Lanterns", emote = "Emotes", emotes = "Emotes", halo = "Halos", halos = "Halos", charm = "Charms", charms = "Charms", cosmetic = "Cosmetics", cosmetics = "Cosmetics", potion = "Potions", potions = "Potions", totem = "Totems", totems = "Totems", booth = "Booths", booths = "Booths", finisher = "Finishers", finishers = "Finishers", ability = "Abilities", abilities = "Abilities", trophy = "Trophies", trophies = "Trophies", thropies = "Trophies", seed = "Seeds", seeds = "Seeds", crop = "Crops", crops = "Crops", plant = "Plants", plants = "Plants", } function M.uploadRanges(raw, size) local ranges, first, length = {}, 1, #raw while first <= length do local last = math.min(first + size - 1, length) if last < length then while last >= first do local byte = string.byte(raw, last + 1) if byte < 128 or byte >= 192 then break end last = last - 1 end end assert(last >= first, "Ukuran chunk terlalu kecil") table.insert(ranges, {first, last}); first = last + 1 end return ranges end function M.category(value) local text = clean(value) return aliases[normalized(text)] or (text ~= "" and text or "Uncategorized") end function M.catalogCategory(value) return aliases[normalized(value)] end function M.catalogPaths() local paths = {} -- Limit discovery to definition roots; never require every client/game module. for _, prefix in ipairs({{}, {"Modules"}, {"Shared"}, {"Shared", "Modules"}, {"Configs"}, {"Definitions"}, {"Catalogs"}}) do for _, name in ipairs({"Items", "Fish", "FishingRods", "Fishing Rods", "Rods", "Baits", "Abilities", "Ability", "Trophies", "Gears", "EnchantStones", "Enchant Stones", "Boats", "Charms", "Emotes", "Halos", "Lanterns", "Pets", "PetEggs", "Pet Eggs", "Potions", "Totems", "Booths", "Finishers", "Cosmetics"}) do local path = table.clone(prefix); table.insert(path, name); table.insert(paths, path) end end return paths end function M.icon(value) if type(value) ~= "string" and type(value) ~= "number" then return "" end local text = clean(value) if text:match("^%d+$") then return tonumber(text) > 0 and "rbxassetid://" .. text or "" end if text:match("^rbxassetid://%d+$") then return tonumber(text:match("(%d+)$")) > 0 and text or "" end if text:match("^rbxasset://") or text:match("^rbxthumb://") then return text end local asset = text:match("[?&]id=(%d+)") if asset and text:lower():find("roblox.com/", 1, true) then return "rbxassetid://" .. asset end -- Arbitrary HTTPS CDN URLs cannot reliably be used as Roblox ImageLabel content. return "" end local function first(record, fields) if type(record) ~= "table" then return nil end for _, field in ipairs(fields) do if record[field] ~= nil then return record[field] end end return nil end M.first = first -- A forced read requested while normalization yields must run after that read. function M.beginRefresh(state, force) if not state.alive or (state.paused and not force) then return false end if state.busy then if force then state.pendingRefresh = true end return false end state.busy = true return true end function M.endRefresh(state) state.busy = false local pending = state.alive and state.pendingRefresh == true state.pendingRefresh = false return pending end function M.catalogDiagnostics(state, now) local elapsed = state.catalogStarted and math.max(0, (state.catalogFinished or now) - state.catalogStarted) or 0 return table.concat({ "Catalog ready: " .. tostring(state.catalogReady == true) .. " | tahap: " .. tostring(state.catalogPhase or "Menunggu"), "Catalog: " .. tostring(state.catalog.count) .. " item; gagal require: " .. tostring(state.catalogFailures or 0), "Modul katalog diperiksa: " .. tostring(state.catalogModules or 0) .. " | durasi: " .. tostring(math.floor(elapsed)) .. " detik", "Modul katalog aktif: " .. tostring(state.catalogModule or "Tidak ada"), "Pemetaan nama mutasi: " .. tostring(state.mutationCount or 0) .. "; gagal require label: " .. tostring(state.catalogLabelFailures or 0), "Catalog error: " .. tostring(state.catalogError or "Tidak ada") .. " | require gagal terakhir: " .. tostring(state.catalogLastFailure or "Tidak ada"), "Root katalog ditemukan:\n" .. table.concat(state.catalogRoots or {}, "\n"), }, "\n") end function M.uuid(value) if type(value) ~= "string" then return nil end local text = clean(value):lower() if text:sub(1, 1) == "{" and text:sub(-1) == "}" then text = text:sub(2, -2) end if #text ~= 36 then return nil end local a, b, c, d, e = text:match("^(%x+)%-(%x+)%-(%x+)%-(%x+)%-(%x+)$") if a and #a == 8 and #b == 4 and #c == 4 and #d == 4 and #e == 12 and c:sub(1, 1) == "4" and d:sub(1, 1):match("[89ab]") then return text end return nil end -- Older package builds and executor module loaders may expose different call styles. function M.clientLookup(client, channel) if type(client) ~= "table" or type(client.GetReplion) ~= "function" then return nil end local ok, value = pcall(client.GetReplion, client, channel) if ok and type(value) == "table" and not value.Destroyed then return value, "colon" end local dotOK, dotValue = pcall(client.GetReplion, channel) if dotOK and type(dotValue) == "table" and not dotValue.Destroyed then return dotValue, "dot" end -- One valid nil result is a missed channel; two exceptions are an API failure. if ok or dotOK then return nil, "nil" end return nil, "error", "colon: " .. tostring(value) .. " | dot: " .. tostring(dotValue) end function M.clientReplion(client, channel) return M.clientLookup(client, channel) end function M.resolveClient(value) if type(value) ~= "table" then return nil end for _, candidate in ipairs({ value.Client or false, value.client or false, value }) do if type(candidate) == "table" and (type(candidate.GetReplion) == "function" or type(candidate.GetReplions) == "function" or type(candidate.WaitReplion) == "function") then return candidate end end return nil end function M.requireLoaders(wrapped, getRuntime) local loaders = { { name = "executor require", call = wrapped } } if type(getRuntime) == "function" then local ok, runtime = pcall(getRuntime) local native = ok and type(runtime) == "table" and runtime.require if type(native) == "function" and native ~= wrapped then table.insert(loaders, { name = "runtime require", call = native }) end end return loaders end function M.cacheReader(api, topLevel) if type(topLevel) == "function" then return topLevel, "getupvalues" end if type(api) == "table" and type(api.getupvalues) == "function" then return api.getupvalues, "debug.getupvalues" end if type(api) == "table" and type(api.getupvalue) == "function" then return function(callback) local results = {} for index = 1, 16 do local ok, name, value = pcall(api.getupvalue, callback, index) if not ok then if index == 1 then error(name) end break end if name == nil then break end local candidate = value ~= nil and value or name if type(candidate) == "table" then table.insert(results, candidate) end end return results end, "debug.getupvalue" end return nil, "Tidak tersedia" end function M.cacheReaders(apis, globals) local readers, seen = {}, {} local function add(fn, name, single) if type(fn) ~= "function" or seen[fn] then return end seen[fn] = true local call = single and M.cacheReader({getupvalue = fn}) or fn table.insert(readers, {call = call, name = name, source = fn}) end globals = globals or {} add(globals.getupvalues, "getupvalues") add(globals.getupvalue, "getupvalue", true) for index, api in ipairs(apis or {}) do if type(api) == "table" then add(api.getupvalues, "debug[" .. index .. "].getupvalues") add(api.getupvalue, "debug[" .. index .. "].getupvalue", true) end end return readers end function M.probeCacheReader(reader) -- This function has one known table upvalue. It never calls a game function. local marker = {} local function target() return marker end local ok, values = pcall(reader.call, target) if not ok then return {status = "error", tables = 0, reason = tostring(values)} end if type(values) ~= "table" then return {status = "failed", tables = 0, reason = "Hasil API bukan tabel"} end local count = 0 for _, value in pairs(values) do if rawequal(value, marker) then return {status = "passed", tables = count + 1, reason = "Upvalue tabel uji terbaca dengan identitas yang tepat"} end if type(value) == "table" then count = count + 1 end end return {status = "failed", tables = count, reason = "API tidak mengembalikan upvalue tabel yang diketahui ada"} end function M.guiRecord(facts, catalog) local attributes = facts.attributes or {} local uid = M.uuid(facts.name) or M.uuid(first(attributes, { "UUID", "Uuid", "uuid", "Uid", "UniqueId", "ItemUUID" })) if not uid and not facts.allowAnonymous then return nil end local name = first(attributes, nameFields) local id = first(attributes, idFields) local category = attributes.Type or attributes.ItemType or attributes.Category local entry = M.lookup(catalog, id, name, category) local meta, extra, namedEntries, quantity = {}, {}, {}, first(attributes, qtyFields) for _, field in ipairs(mutationFields) do if attributes[field] ~= nil then meta[field] = attributes[field] end end if attributes.Modifier ~= nil then meta.Mutation = meta.Mutation or attributes.Modifier end local labels = {} for _, label in ipairs(facts.labels or {}) do table.insert(labels, label) if label.text:find("\n", 1, true) then for line in label.text:gmatch("[^\r\n]+") do table.insert(labels, { name = label.name, text = line }) end end end local function labelPriority(label) local name = tostring(label.name):lower() return (name == "name" or name == "title" or name == "itemname" or name == "displayname") and 1 or 0 end table.sort(labels, function(a, b) return labelPriority(a) > labelPriority(b) end) for _, label in ipairs(labels) do local text = clean(label.text):gsub("<[^>]*>", "") local candidate = text:gsub("\n", " ") for _ = 1, 3 do local stripped = false for _, flag in ipairs({ "Shiny", "Big", "Mega" }) do if candidate:sub(1, #flag + 1):lower() == flag:lower() .. " " then meta[flag] = true; candidate = candidate:sub(#flag + 2); stripped = true end end if not stripped then break end end local labelName = tostring(label.name):lower() local mutationLabel = labelName:find("mutation", 1, true) or labelName:find("variant", 1, true) or labelName:find("modifier", 1, true) local mappedMutation = catalog.mutationNames and catalog.mutationNames[normalized(text)] local match = not mutationLabel and (not mappedMutation or not entry or labelPriority(label) == 1) and M.lookup(catalog, nil, candidate, category) if not match and not mutationLabel and labelPriority(label) == 1 then local suffixMatch, suffixName for _, mutationName in pairs(catalog.mutationNames or {}) do local suffix = " " .. mutationName if candidate:sub(-#suffix):lower() == suffix:lower() then local base = M.lookup(catalog, nil, candidate:sub(1, -#suffix - 1), category) if base and (not suffixName or #mutationName > #suffixName) then suffixMatch = base; suffixName = mutationName end end end if suffixMatch then match = suffixMatch; meta.Mutations = meta.Mutations or {}; meta.Mutations[suffixName] = true end end if match then namedEntries[match.key] = true; entry = match; name = match.name end local number, suffix = text:lower():gsub(",", ""):match("^(%d+%.?%d*)%s*([kmb]?)%s*kg$") if number then meta.Weight = tonumber(number) * ({ k = 1000, m = 1000000, b = 1000000000, [""] = 1 })[suffix] local decimals = #(number:match("%.(%d+)") or "") meta.WeightResolution = ({ k = 1000, m = 1000000, b = 1000000000, [""] = 1 })[suffix] / 10 ^ decimals end if mutationLabel or (mappedMutation and not match) then meta.Mutations = meta.Mutations or {} if type(meta.Mutation) == "string" or type(meta.Mutation) == "number" then meta.Mutations[meta.Mutation] = true end meta.Mutations[mappedMutation or text] = true meta.Mutation = nil -- All recognized mutation labels stay available as a set. end if label.name and (label.name:lower() == "itemname" or label.name:lower() == "displayname") and not name then name = candidate end local stack = text:match("^[xX]%s*(%d+)$") or text:match("^(%d+)%s*[xX]$") or text:match("^(%d+)$") if stack and label.name and (label.name:lower():find("amount", 1, true) or label.name:lower():find("quantity", 1, true) or label.name:lower():find("count", 1, true)) then quantity = tonumber(stack) end table.insert(extra, text) end local nameCount = 0; for _ in pairs(namedEntries) do nameCount = nameCount + 1 end if nameCount > 1 then return nil end -- An ancestor containing several cards is not one item. local icon = M.icon(first(attributes, iconFields)) if icon == "" and entry then icon = entry.icon end for _, value in ipairs(facts.images or {}) do if icon == "" or value.name:lower() == "itemicon" or value.name:lower() == "icon" then local image = M.icon(value.image); if image ~= "" then icon = image end end end if not uid then local realImage = false for _, value in ipairs(facts.images or {}) do if M.icon(value.image) ~= "" then realImage = true; break end end if not entry or not realImage or facts.truncated then return nil end end if not name and not entry and not id then return nil end return { UUID = uid, Id = id or (entry and entry.id), Name = name or (entry and entry.name), Type = category or (entry and entry.category), Icon = icon, Quantity = quantity, Metadata = meta, GuiPath = facts.path, Reader = "GUI", UUIDUnavailable = not uid, GuiText = table.concat(extra, " | ") } end function M.clientCandidates(client, channels, getUpvalues) local results, seen = {}, {} local diagnostics = {lookups = 0, emptyLookups = 0, lookupErrors = 0, cacheReads = 0, cacheTables = 0, cacheErrors = 0, cacheCandidates = 0, cacheNotes = {}} local function add(value, origin) if type(value) == "table" and not value.Destroyed and not seen[value] and (type(value.Data) == "table" or type(value.Get) == "function") then seen[value] = true; table.insert(results, { value = value, origin = origin }) end end local seenChannels = {} for _, channel in ipairs(channels) do if not seenChannels[channel] then seenChannels[channel] = true; diagnostics.lookups = diagnostics.lookups + 1 local value, mode, err = M.clientLookup(client, channel) add(value, tostring(channel)) if not value then if mode == "error" then diagnostics.lookupErrors = diagnostics.lookupErrors + 1; diagnostics.lastLookupError = err else diagnostics.emptyLookups = diagnostics.emptyLookups + 1 end end end end for _, field in ipairs({"Replions", "_replions", "Cache", "_cache", "cache"}) do local cache = client[field] if type(cache) == "table" then local count = 0 for key, value in next, cache do count = count + 1; if count > 64 then break end add(value, "Client." .. field .. ":" .. tostring(key)) end end end if type(client.GetReplions) == "function" then local ok, values = pcall(client.GetReplions, client) if not ok or type(values) ~= "table" then ok, values = pcall(client.GetReplions) end if ok and type(values) == "table" then local count = 0 for key, value in pairs(values) do count = count + 1; if count > 64 then break end add(value, "GetReplions:" .. tostring(key)) end end end -- Read only cache upvalues of this Client's lookup functions. Never invoke waiting APIs. local readers = type(getUpvalues) == "function" and {{call = getUpvalues, name = "upvalues"}} or getUpvalues or {} local targets, targetSeen = {}, {} for _, field in ipairs({"GetReplion", "WaitReplion", "AwaitReplion"}) do if type(client[field]) == "function" and not targetSeen[client[field]] then targetSeen[client[field]] = true; table.insert(targets, {call = client[field], name = field}) end end for _, reader in ipairs(readers) do local remaining = 256 for _, target in ipairs(targets) do diagnostics.cacheReads = diagnostics.cacheReads + 1 local ok, upvalues = pcall(reader.call, target.call) local tables, found = 0, 0 if ok and type(upvalues) == "table" then for _, cache in pairs(upvalues) do if type(cache) == "table" then tables = tables + 1 for key, value in next, cache do remaining = remaining - 1; if remaining < 0 then break end if type(value) == "table" and type(value._channel) == "string" and (type(value.Get) == "function" or type(value.Data) == "table") then local before = #results add(value, "client cache:" .. target.name .. ":" .. tostring(key)) if #results > before then found = found + 1 end end end end if remaining < 0 then break end end diagnostics.cacheTables = diagnostics.cacheTables + tables diagnostics.cacheCandidates = diagnostics.cacheCandidates + found else diagnostics.cacheErrors = diagnostics.cacheErrors + 1 end table.insert(diagnostics.cacheNotes, reader.name .. "/" .. target.name .. ": " .. ((ok and type(upvalues) == "table") and (tables .. " tabel; " .. found .. " replion baru") or ("gagal: " .. tostring(upvalues)))) end end return results, diagnostics end function M.guiCards(candidates) table.sort(candidates, function(a, b) if (a.record.UUID ~= nil) ~= (b.record.UUID ~= nil) then return a.record.UUID ~= nil end return #(a.ancestors or {}) > #(b.ancestors or {}) end) local records, kept, ancestors, uuids = {}, {}, {}, {} for _, candidate in ipairs(candidates) do local duplicate = kept[candidate.id] or ancestors[candidate.id] for _, parent in ipairs(candidate.ancestors or {}) do if kept[parent] then duplicate = true; break end end local uid = candidate.record.UUID if uid and uuids[uid] then duplicate = true end if not duplicate then kept[candidate.id] = true for _, parent in ipairs(candidate.ancestors or {}) do ancestors[parent] = true end if uid then uuids[uid] = true end table.insert(records, candidate.record) end end return records end function M.guiScope(facts) if not facts.localPlayerGui then return false, "Area GUI bukan PlayerGui akun lokal" end if not facts.selectedList then return false, "Pilih daftar tas secara eksplisit" end if facts.blocked then return false, "Area papan peringkat/katalog/shop/trade bukan isi tas" end if not facts.visible and not facts.confirmedList then return false, "Daftar tas atau parent-nya tersembunyi" end return true, "Daftar tas dipilih pengguna" end function M.blockedGuiName(name) name = tostring(name):lower() for _, word in ipairs({ "template", "equipped", "hotbar", "shop", "store", "reward", "trade", "craft", "index", "encyclopedia", "highscore", "leaderboard", "ranking" }) do if name:find(word, 1, true) then return true end end return false end function M.guiHitList(hit, localPlayerGui, ownGui) if not hit:IsDescendantOf(localPlayerGui) or (ownGui and hit:IsDescendantOf(ownGui)) then return nil end local cursor, scrolling = hit, nil while cursor and cursor ~= localPlayerGui do if M.blockedGuiName(cursor.Name) then return nil, "Panel ini papan peringkat/katalog, bukan tas" end if cursor:IsA("GuiObject") and not cursor.Visible then return nil, "Panel yang diklik tersembunyi" end if cursor:IsA("ScreenGui") and not cursor.Enabled then return nil, "Panel yang diklik tidak aktif" end if not scrolling and cursor:IsA("ScrollingFrame") then scrolling = cursor end cursor = cursor.Parent end return scrolling end function M.guiItemScope(node, selected) local cursor = node while cursor and cursor ~= selected do if M.blockedGuiName(cursor.Name) then return false end if cursor:IsA("GuiObject") and not cursor.Visible then return false end cursor = cursor.Parent end -- The confirmed list/outer window may be closed; hidden cards within it still stay excluded. return cursor ~= nil and cursor == selected end function M.profileScope(data, localUserId, channel, replicateAll, channels) if type(data) ~= "table" then return false, "Profile bukan tabel" end local sources = { data, type(data.Profile) == "table" and data.Profile or false, type(data.Data) == "table" and data.Data or false } local owned = false for _, value in ipairs(sources) do if type(value) == "table" then for _, field in ipairs({ "UserId", "OwnerUserId", "PlayerUserId" }) do if value[field] ~= nil then if tonumber(value[field]) ~= tonumber(localUserId) then return false, "UserId profile berbeda dari LocalPlayer" end owned = true end end end end if owned then return true, "UserId profile cocok LocalPlayer" end if replicateAll then return false, "Replion dibagikan ke semua player tanpa pemilik yang cocok" end if type(channel) ~= "string" or not table.find(channels or {}, channel) then return false, "Channel belum terbukti sebagai profile akun lokal" end return true, "Channel personal client; UserId tidak diekspos" end local function insertIndex(index, key, entry) if key == nil then return end key = tostring(key) local previous = index[key] if previous and previous.key ~= entry.key then index[key] = false elseif previous ~= false then index[key] = entry end end function M.catalog() local catalog = { byId = {}, byName = {}, byCategory = {}, namesByCategory = {}, byCollection = {}, count = 0, tiers = {}, variants = {}, mutationNames = {}, enchants = {} } -- Names visible in the supplied inventory screenshot; game catalog entries can extend these. for _, name in ipairs({"Holographic", "Bloodmoon", "Gemstone", "Fairy Dust", "Radioactive", "Galaxy", "Stone", "Moon Fragment", "Midnight", "Frozen", "Ghost", "Sandy", "Albino", "Lightning", "Cupid", "Disco", "Corrupt", "Gold", "Festive"}) do catalog.mutationNames[normalized(name)] = name end return catalog end function M.addMutation(catalog, name, id) name = clean(name) if name == "" then return end catalog.mutationNames[normalized(name)] = name catalog.variants[name] = name if id ~= nil then catalog.variants[tostring(id)] = name end end function M.addCatalog(catalog, value, fallbackName, fallbackCategory, fallbackId, collection) if type(value) ~= "table" then return false end local data = type(value.Data) == "table" and value.Data or value local name = clean(first(data, nameFields) or first(value, nameFields) or fallbackName) local id = first(data, idFields) or first(value, idFields) or fallbackId local image = first(data, iconFields) or first(value, iconFields) if name == "" or (id == nil and image == nil and data.Type == nil) then return false end local category = M.category(data.Type or data.Category or value.Type or value.Category or fallbackCategory) local entry = { id = id and tostring(id), name = name, category = category, icon = M.icon(image), tier = data.Tier or data.Rarity, key = category .. "\0" .. tostring(id or name), tradeData = tradeData(value), tradeRules = type(value.Data) == "table" and type(value.Data.Type) == "string" and { itemType = value.Data.Type, isSkin = not not value.IsSkin, hasPrice = not not value.Price, tradeLocked = not not value.TradeLocked, hasModifiers = not not value.Modifiers, } or nil, } catalog.byCategory[category] = catalog.byCategory[category] or {} local existing = catalog.byCategory[category][entry.id or name] if collection then catalog.byCollection = catalog.byCollection or {} catalog.byCollection[collection] = catalog.byCollection[collection] or {} insertIndex(catalog.byCollection[collection], entry.id, existing or entry) end if existing then -- A shared definition may omit the icon that its dedicated catalog provides. if existing.name == entry.name then if existing.icon == "" and entry.icon ~= "" then existing.icon = entry.icon end if existing.tier == nil then existing.tier = entry.tier end if entry.tradeRules then existing.tradeRules = entry.tradeRules end existing.tradeData = existing.tradeData or {} for field, flag in pairs(entry.tradeData) do if existing.tradeData[field] == nil then existing.tradeData[field] = flag end end end return false end catalog.byCategory[category][entry.id or name] = entry catalog.namesByCategory = catalog.namesByCategory or {} catalog.namesByCategory[category] = catalog.namesByCategory[category] or {} insertIndex(catalog.namesByCategory[category], normalized(name), entry) insertIndex(catalog.byId, entry.id, entry) insertIndex(catalog.byName, normalized(name), entry) catalog.count = catalog.count + 1 return true end function M.ingestCatalog(catalog, value, fallbackName, fallbackCategory, options) options = options or {} local stats = {added = 0, visited = 0, truncated = false} local seen = {} local ignoredFields = {metadata = true, meta = true, assets = true, stats = true, statistics = true, modifiers = true, mutations = true, enchants = true, enchantments = true, recipes = true, ingredients = true, requirements = true, rewards = true, drops = true} local walk walk = function(node, name, category, keyId, depth) if type(node) ~= "table" or seen[node] then return end if stats.visited >= (options.MaxNodes or 20000) or depth > 12 then stats.truncated = true; return end seen[node] = true; stats.visited = stats.visited + 1 if options.Yield and stats.visited % 200 == 0 then options.Yield() end local data = type(node.Data) == "table" and node.Data or node local id = first(data, idFields) or first(node, idFields) local title = first(data, nameFields) or first(node, nameFields) local image = first(data, iconFields) or first(node, iconFields) -- A container can have Name/Icon too; require an actual ID for it to be a descriptor. local hasChildren = false for key, child in pairs(data) do if type(child) == "table" and not ignoredFields[normalized(key)] then hasChildren = true; break end end local descriptor = id ~= nil or (not hasChildren and (title ~= nil or image ~= nil)) if descriptor then if M.addCatalog(catalog, node, name, category, keyId, options.Collection) then stats.added = stats.added + 1 end return -- Metadata/rewards within a definition are not more item definitions. end local array = #data > 0 if array then local size = 0 for key in pairs(data) do size = size + 1 if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > #data then array = false; break end end array = array and size == #data end for key, child in pairs(data) do if type(child) == "table" and not ignoredFields[normalized(key)] then local childCategory = M.catalogCategory(key) or category local numericKey = (type(key) == "number" or type(key) == "string") and tonumber(key) -- Numeric string keys are explicit IDs; sparse numeric maps are IDs too. -- Dense numeric arrays are positions, so never invent IDs from their indexes. local childId = numericKey and (type(key) == "string" or not array) and numericKey or nil local childName = not numericKey and not M.catalogCategory(key) and tostring(key) or nil walk(child, childName, childCategory, childId, depth + 1) end end end walk(value, fallbackName, fallbackCategory, nil, 0) return stats end function M.lookup(catalog, id, name, category, collection) category = M.category(category) if category == "Items" then collection = "Items"; category = "Uncategorized" end local scoped = catalog.byCategory[category] local registry = collection and catalog.byCollection and catalog.byCollection[collection] local byId = id ~= nil and ((scoped and scoped[tostring(id)]) or (registry and registry[tostring(id)]) or catalog.byId[tostring(id)]) if byId and category ~= "Uncategorized" and byId.category ~= category then byId = nil end if byId then return byId end local scopedNames = catalog.namesByCategory and catalog.namesByCategory[category] local byName = name and ((scopedNames and scopedNames[normalized(name)]) or catalog.byName[normalized(name)]) if byName and (category == "Uncategorized" or byName.category == category) then return byName end return nil end function M.unmappedCatalogRows(rows) local groups, lines = {}, {} for _, row in ipairs(rows or {}) do if not row.resolved or row.icon == "" then local group = groups[row.category] or {count = 0, samples = {}} groups[row.category] = group; group.count = group.count + 1 if #group.samples < 4 then table.insert(group.samples, "ID=" .. (row.id ~= "" and row.id or "tanpa ID") .. " nama=" .. row.name .. (row.resolved and " (ikon belum ditemukan)" or " (definisi belum ditemukan)")) end end end for category, group in pairs(groups) do table.insert(lines, category .. " [" .. group.count .. "]: " .. table.concat(group.samples, "; ")) end table.sort(lines) return #lines > 0 and table.concat(lines, "\n") or "Tidak ada" end function M.path(root, path) local cursor = root for _, part in ipairs(path) do if type(cursor) ~= "table" then return nil end cursor = cursor[part] end return cursor end function M.inventory(data, paths) for _, path in ipairs(paths) do local value = M.path(data, path) if type(value) == "table" then return value, table.concat(path, ".") end end return nil end local function metadata(record) return type(record.Metadata) == "table" and record.Metadata or type(record.Meta) == "table" and record.Meta or {} end function M.tradeEligibility(record, entry) record = record or {} local facts = type(record.TradeData) == "table" and record.TradeData or tradeData(record) local allowed, blocked, rap, proof for index, source in ipairs({facts, entry and entry.tradeData or {}}) do for fieldIndex, field in ipairs(tradeFields) do local value = source[field] local origin = (index == 1 and "record." or "catalog.") .. field if fieldIndex <= 5 and type(value) == "boolean" then if value then allowed = origin else blocked = origin end elseif type(value) == "number" and value == value and value > 0 and value < math.huge then if field == "RAP" or field == "Rap" or field == "rap" or field == "RecentAveragePrice" then rap = value; proof = origin end end end end local rules = entry and entry.tradeRules if rules then local supported = {Fish=true, ["Fishing Rods"]=true, Boats=true, Baits=true, Charms=true, Emotes=true, Halos=true, Lanterns=true, ["Enchant Stones"]=true, Pets=true, ["Pet Eggs"]=true, Gears=true, Potions=true} local skinOnly = rules.itemType == "Baits" or rules.itemType == "Fishing Rods" local excludesCoins = skinOnly or rules.itemType == "Charms" or rules.itemType == "Boats" or rules.itemType == "Lanterns" or rules.itemType == "Emotes" local meta = metadata(record) local cargo = record.PetCargo local permitsModifiers = rules.itemType == "Potions" or rules.itemType == "Pets" or rules.itemType == "Charms" local valid = supported[rules.itemType] and (not skinOnly or rules.isSkin) and (not excludesCoins or not rules.hasPrice) and not rules.tradeLocked and not meta.TradeLock and not (type(cargo) == "table" and #cargo > 0) and (permitsModifiers or not rules.hasModifiers) return {status = valid and "tradable" or "notTradable", rap = rap, source = "TradeData.FollowTradeRules (source)"} end if blocked then return {status = "notTradable", rap = rap, source = blocked} end if allowed then return {status = "tradable", rap = rap, source = allowed} end return {status = "unknown", rap = rap, source = rap and "RAP tersedia; aturan trade belum terbaca" or "Aturan trade belum tersedia"} end function M.tradeRAP(data, itemType, id) local raps = type(data) == "table" and data.RAPs local category = type(raps) == "table" and raps[itemType] local value = type(category) == "table" and category[itemType .. "/" .. tostring(id)] if type(value) == "number" and value == value and value >= 0 and value < math.huge then return value end return nil end local function copyMutationValue(value) local remaining,active=128,{} local function copy(child,depth) if type(child)~="table" then local kind=type(child) if kind=="number" and (child~=child or math.abs(child)==math.huge) then error("Angka mutasi tidak valid",0) end if kind=="string" or kind=="number" or kind=="boolean" or kind=="nil" then return child end error("Tipe field mutasi tidak didukung: " .. kind,0) end if active[child] then error("Referensi mutasi melingkar",0) end if depth>8 then error("Field mutasi terlalu dalam",0) end active[child]=true local out={} for key,item in pairs(child) do remaining-=1; if remaining<0 then error("Field mutasi melebihi batas salinan",0) end if type(key)~="string" and type(key)~="number" then error("Key field mutasi tidak didukung",0) end out[key]=copy(item,depth+1) end active[child]=nil; return out end local ok,copied=pcall(copy,value,0) if ok then return copied end return nil,tostring(copied) end function M.itemVariants(record, catalog, displayName) local parts, seen, traits, evidence, issues, unmapped = {}, {}, {}, {}, {}, {} local present, explicitNone, visited = false, false, 0 local function issue(text) if #issues < 8 then table.insert(issues, text) end end local function add(part) if part == false then explicitNone = true; return end if type(part) ~= "string" and type(part) ~= "number" then issue("Nilai mutasi bukan nama/ID: " .. type(part)); return end if type(part)=="number" and (part~=part or math.abs(part)==math.huge) then issue("ID mutasi bukan angka finite"); return end local text = clean(part) if text == "" or text:lower() == "none" or text:lower() == "normal" or text == "0" then explicitNone = true; return end local mapped = catalog.variants and catalog.variants[text] or (catalog.mutationNames and catalog.mutationNames[normalized(text)]) if mapped then text = mapped elseif tonumber(text) then unmapped[text] = true; text = "Mutasi #" .. text end for _, flag in ipairs({"Shiny", "Big", "Mega"}) do if normalized(text) == normalized(flag) then traits[flag] = true; return end end if not seen[text] then seen[text] = true; table.insert(parts, text) end end local read read = function(value, depth, active) visited = visited + 1 if visited > 128 or depth > 4 then issue("Batas pembacaan field mutasi tercapai"); return end if type(value) == "table" then if active[value] then issue("Field mutasi memiliki referensi melingkar"); return end active[value] = true local descriptor = first(value, {"Name", "Id", "MutationId", "VariantId"}) if descriptor ~= nil then read(descriptor, depth + 1, active) elseif next(value) == nil then explicitNone = true else for key, part in pairs(value) do if visited >= 128 then issue("Batas pembacaan field mutasi tercapai"); break end if part == true then add(key) else read(part, depth + 1, active) end end end active[value] = nil else add(value) end end local sources = {{data=record,path="record"}} local function source(value,path) if type(value)=="table" then table.insert(sources,{data=value,path=path}) end end source(record.Metadata,"Metadata"); source(record.Meta,"Meta") if type(record.Data) == "table" then source(record.Data,"Data"); source(record.Data.Metadata,"Data.Metadata"); source(record.Data.Meta,"Data.Meta") end local function consume(value,path) present = true; read(value,0,{}) if #evidence < 64 then local copied,err = copyMutationValue(value) if err then issue(err) end table.insert(evidence,{path=path,value=copied,error=err and tostring(err) or nil}) end end -- Preserve raw field provenance through normalized bag records. Numeric IDs must be -- remapped when the mutation catalog arrives, rather than becoming permanent labels. if type(record.MutationEvidence)=="table" then for index,entry in ipairs(record.MutationEvidence) do if index>64 then issue("Bukti field mutasi terpotong"); break end if type(entry)=="table" and type(entry.path)=="string" then if entry.error then present=true; issue(entry.error); table.insert(evidence,{path=entry.path,error=entry.error}) else consume(entry.value,entry.path) end end end else for _, entry in ipairs(sources) do for _, field in ipairs(mutationFields) do if entry.data[field]~=nil then consume(entry.data[field],entry.path .. "." .. field) end end end end if type(displayName) == "string" then source({Name=displayName},"displayName") end for _, entry in ipairs(sources) do local value = entry.data for _, flag in ipairs({"Shiny", "Big", "Mega"}) do if value[flag] == true then traits[flag] = true end end local name = first(value, nameFields) if type(name) == "string" then for token in name:gmatch("%S+") do if token == "Shiny" or token == "Big" or token == "Mega" then traits[token] = true else break end end end end table.sort(parts) local combined = table.clone(parts) for _, flag in ipairs({ "Shiny", "Big", "Mega" }) do if traits[flag] then table.insert(combined, flag) end end table.sort(combined) local status = next(unmapped) and "unmapped" or (#issues>0 and "invalid") or (#parts>0 and "known") or ((present and (explicitNone or next(traits))) and "none") or "unknown" local reason = status=="unknown" and "Field mutasi belum tersedia; ketiadaan field tidak membuktikan Normal." or status=="none" and "Field mutasi eksplisit kosong/Normal atau hanya berisi trait." or status=="unmapped" and "ID mutasi tersedia tetapi nama belum ada pada katalog." or status=="invalid" and table.concat(issues,"; ") or "Nama mutasi terbaca dari field item." return {mutation = table.concat(parts, " + "), mutations = parts, traits = traits, variant = table.concat(combined, " + "), status=status,reason=reason,evidence=evidence,issues=issues,unmappedIds=unmapped} end function M.mutationDisplay(row) local text = row.mutation or "" if row.mutationStatus=="unmapped" then return text .. " (nama belum dipetakan)" end if row.mutationStatus=="invalid" then return (text~="" and text .. " | " or "") .. "Data belum terbaca lengkap" end if text~="" then return text end return row.mutationStatus=="none" and "Normal" or "Belum terbaca" end local function mutationPreview(value, depth, active) if type(value)~="table" then return tostring(value):gsub("[%c]"," "):sub(1,150) end if depth>=2 or active[value] then return "{...}" end active[value]=true local parts={} for key,child in pairs(value) do if #parts>=6 then table.insert(parts,"..."); break end table.insert(parts,tostring(key):sub(1,40) .. "=" .. mutationPreview(child,depth+1,active)) end active[value]=nil; table.sort(parts) return "{" .. table.concat(parts,", ") .. "}" end function M.mutationDiagnostics(snapshot) local data=snapshot and snapshot.mutationDiagnostics if not data then return "Mutasi item: belum diperiksa" end local lines={"Mutasi Fish/Gears/Items: " .. data.total .. " record; terbaca=" .. data.known .. "; normal eksplisit=" .. data.none .. "; ID belum dipetakan=" .. data.unmapped .. "; belum tersedia=" .. data.unknown .. "; format gagal=" .. data.invalid, "Field yang diperiksa: record/Metadata/Meta/Data; VariantId/VariantIds, MutationId/MutationIds, Variant, Mutation, Mutations.", "Contoh field mutasi item (maksimal 3 per status):"} for _, sample in ipairs(data.samples) do table.insert(lines,sample.category .. " | " .. sample.name .. " | ID=" .. sample.id .. " | UUID=" .. sample.uuid .. " | " .. sample.status .. " | " .. sample.reason) for _, evidence in ipairs(sample.evidence) do table.insert(lines," " .. evidence.path .. "=" .. (evidence.error and ("Gagal menyalin: " .. evidence.error) or mutationPreview(evidence.value,0,{}))) end if #sample.evidence==0 then table.insert(lines," Field mutasi yang didukung: tidak ditemukan") end if sample.metadataKeys~="" then table.insert(lines," Field metadata tersedia: " .. sample.metadataKeys) end for _, hint in ipairs(sample.hints) do table.insert(lines," Field terkait belum dibaca: " .. hint) end end if snapshot.truncated then table.insert(lines,"Pembacaan inventory terpotong; ringkasan mutasi hanya mencakup record yang sempat dibaca.") end return table.concat(lines,"\n") end function M.displayItemName(row) local prefix = {} for _, flag in ipairs({"Mega", "Big", "Shiny"}) do if row.traits and row.traits[flag] and not (" " .. row.name .. " "):find(" " .. flag .. " ", 1, true) then table.insert(prefix, flag) end end return (#prefix > 0 and table.concat(prefix, " ") .. " " or "") .. row.name end local wrappers = { items = true, inventory = true, data = true, contents = true, entries = true } local ignored = { metadata = true, meta = true, equipped = true, equippeditems = true, equippedrods = true, equippedbait = true, capacity = true, maxcapacity = true, version = true, equippedrod = true, equippedfishingrod = true, equippedabilities = true, equippedability = true, equippedrodid = true, equippedroduuid = true, equippedbaitid = true, equippedabilityid = true, equippedpet = true, equippedpetid = true, equippedpetuuid = true, equippedpotions = true, equippedpotionuuid = true, equippedpotionid = true, selected = true, selecteditem = true, lastupdated = true, } local function isArray(value) local length, count = #value, 0 if length == 0 then return false end for key in pairs(value) do if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > length then return false end count = count + 1 end return count == length end local function itemRecord(value) return type(value) == "table" and (first(value, idFields) ~= nil or first(value, nameFields) ~= nil or first(value, qtyFields) ~= nil or first(value, uniqueFields) ~= nil) end function M.normalize(inventory, catalog, options) options = options or {} local result = { rows = {}, total = 0, unresolved = 0, missingIcons = 0, visited = 0, skipped = 0, truncated = false, mutationDiagnostics={total=0,known=0,none=0,unmapped=0,unknown=0,invalid=0,samples={}} } local mutationSamples={} if options.CollectRecords then result.items = {} end local groups, seenTables, seenInstances = {}, {}, {} local limit = options.MaxNodes or 200000 local function emit(record, key, category, path, scalarQty, collection) if type(record.Data) == "table" then local merged = table.clone(record) for field, value in pairs(record.Data) do if merged[field] == nil then merged[field] = value end end record = merged end local id = first(record, idFields) local name = first(record, nameFields) local uid = first(record, uniqueFields) or M.uuid(key) uid = M.uuid(uid) or uid if id == nil and name == nil and type(key) == "string" and not M.uuid(key) then if key:match("^%d+$") then id = key else name = key end end if id == nil and name == nil and not uid then result.skipped = result.skipped + 1; return end category = M.category(record.Type or record.Category or category) local entry = M.lookup(catalog, id, name, category, collection) category = M.category((entry and entry.category) or record.Type or record.Category or category) if entry and (name == "Item UUID" or (id ~= nil and name == "Item #" .. tostring(id))) then name = nil end name = clean(name or (entry and entry.name) or (id and "Item #" .. tostring(id)) or (uid and "Item UUID") or key) local rawQty = scalarQty if rawQty == nil then rawQty = first(record, qtyFields) end local qty = rawQty == nil and 1 or tonumber(rawQty) if not qty or qty ~= qty or qty == math.huge or qty <= 0 then result.skipped = result.skipped + 1; return end if uid then local uniqueKey = tostring(uid) if seenInstances[uniqueKey] then return end seenInstances[uniqueKey] = true end local variants = M.itemVariants(record, catalog, name) local mutation = variants.variant local meta = metadata(record) if category=="Fish" or category=="Gears" or category=="Items" or category=="Uncategorized" then local diagnostic=result.mutationDiagnostics diagnostic.total+=1; diagnostic[variants.status]+=1 if (mutationSamples[variants.status] or 0)<3 then mutationSamples[variants.status]=(mutationSamples[variants.status] or 0)+1 local keys,hints={},{} local supported={}; for _, field in ipairs(mutationFields) do supported[field]=true end local function inspect(value,origin,metadataKeys) if type(value)~="table" then return end local fields,checked={},0 for field,child in pairs(value) do checked+=1; if checked>64 then table.insert(fields,"..."); break end if #fields<16 then table.insert(fields,tostring(field):sub(1,60)) end local lower=tostring(field):lower() if not supported[field] and field~="MutationEvidence" and field~="MutationStatus" and (lower:find("mutation",1,true) or lower:find("variant",1,true)) and #hints<8 then table.insert(hints,origin .. "." .. tostring(field) .. "=" .. mutationPreview(child,0,{})) end end if metadataKeys then table.sort(fields); table.insert(keys,origin .. ": " .. table.concat(fields,", ")) end end inspect(record,"record",false); inspect(record.Metadata,"Metadata",true); inspect(record.Meta,"Meta",true) if type(record.Data)=="table" then inspect(record.Data,"Data",false); inspect(record.Data.Metadata,"Data.Metadata",true); inspect(record.Data.Meta,"Data.Meta",true) end table.insert(diagnostic.samples,{category=category,name=name,id=tostring(id or ""),uuid=tostring(uid or "Belum tersedia"), status=variants.status,reason=variants.reason,evidence=variants.evidence,metadataKeys=table.concat(keys," | "),hints=hints}) end end local rarity = record.Rarity or record.rarity or record.Tier or (entry and entry.tier) rarity = rarity ~= nil and (catalog.tiers[tostring(rarity)] or (type(rarity) == "number" and "Tier " .. tostring(rarity)) or tostring(rarity)) or "Unknown" local icon = M.icon(first(record, iconFields)) if icon == "" and entry then icon = entry.icon end local groupKey = category .. "\0" .. tostring((entry and entry.id) or id or (name == "Item UUID" and uid) or name) .. "\0" .. name .. "\0" .. mutation .. "\0" .. rarity .. "\0" .. variants.status local row = groups[groupKey] if not row then row = { key = groupKey, id = tostring((entry and entry.id) or id or ""), name = name, category = category, variant = mutation, mutation = variants.mutation, mutations = variants.mutations, traits = variants.traits, mutationStatus = variants.status, mutationReason = variants.reason, rarity = rarity, icon = icon, qty = 0, records = 0, favoriteQty = 0, lockedQty = 0, instances = {}, paths = {}, resolved = entry ~= nil, } groups[groupKey] = row table.insert(result.rows, row) end if row.icon == "" then row.icon = icon end row.qty = row.qty + qty row.records = row.records + 1 result.total = result.total + qty if record.Favorite == true or record.Favorited == true or meta.Favorite == true or meta.Favorited == true then row.favoriteQty = row.favoriteQty + qty end if record.Locked == true or record.TradeLocked == true or meta.Locked == true or meta.TradeLocked == true then row.lockedQty = row.lockedQty + qty end local weight = tonumber(record.Weight or record.Kg or meta.Weight or meta.Kg) if weight and weight == weight and weight >= 0 and weight < math.huge then row.minWeight = math.min(row.minWeight or weight, weight) row.maxWeight = math.max(row.maxWeight or weight, weight) end if uid then table.insert(row.instances, tostring(uid)) end if result.items then local copiedMeta = M.copyData(meta) or {} for _, flag in ipairs({"Big", "Shiny", "Mega"}) do if record[flag] == true then copiedMeta[flag] = true end end table.insert(result.items, {Id = (entry and entry.id) or id, Name = name, Type = category, UUID = uid, Quantity = qty, Icon = icon, Rarity = rarity, Metadata = copiedMeta, GroupKey = groupKey, Mutation = #variants.mutations == 1 and variants.mutations[1] or nil, Mutations = table.clone(variants.mutations), MutationEvidence = variants.evidence, MutationStatus = variants.status, Shiny = variants.traits.Shiny, Big = variants.traits.Big, Mega = variants.traits.Mega, Weight = weight, Favorited = record.Favorited, Reader = record.Reader, Locked = record.Locked, TradeLocked = record.TradeLocked, TradeLock = M.copyData(record.TradeLock, 128), TradeData = tradeData(record), PetCargo = M.copyData(record.PetCargo, 128), GuiPath = record.GuiPath, Enchantments = first(record, {"Enchantments", "Enchants", "Enchantment", "Enchant", "EnchantId", "EnchantmentId"}), SecondEnchantments = first(record, secondaryEnchantFields)}) end if #row.paths < 5 then table.insert(row.paths, path) end end local walk walk = function(value, category, path, depth, key, collection) result.visited = result.visited + 1 if result.visited > limit or depth > 12 then result.truncated = true; return end if options.Yield and result.visited % 400 == 0 then options.Yield() end if type(value) ~= "table" or seenTables[value] then return end seenTables[value] = true if itemRecord(value) or M.uuid(key) or (type(value.Data) == "table" and itemRecord(value.Data)) then emit(value, key, category, path, nil, collection); return end local array = isArray(value) for childKey, child in pairs(value) do if result.visited >= limit then result.truncated = true; break end local keyText, childCategory = tostring(childKey), category local normalizedKey = normalized(keyText) local childCollection = normalizedKey == "items" and "Items" or collection local childPath = path .. "." .. keyText if not ignored[normalizedKey] then if type(child) == "table" then if type(childKey) == "string" and not itemRecord(child) and not wrappers[normalizedKey] and not childKey:match("^%d+$") and not M.uuid(childKey) then childCategory = M.category(keyText) end walk(child, childCategory, childPath, depth + 1, childKey, childCollection) elseif type(child) == "number" or type(child) == "string" or child == true then result.visited = result.visited + 1 if options.Yield and result.visited % 400 == 0 then options.Yield() end if array and (type(child) == "number" or type(child) == "string") then if type(child) == "number" or tostring(child):match("^%d+$") then emit({ Id = child }, childKey, childCategory, childPath, nil, childCollection) else emit({ Name = child }, childKey, childCategory, childPath, nil, childCollection) end elseif child == true then emit({}, keyText, childCategory, childPath, 1, childCollection) elseif tonumber(child) then emit({}, keyText, childCategory, childPath, tonumber(child), childCollection) else result.skipped = result.skipped + 1 end end end end end walk(inventory, options.Category or "Uncategorized", options.Path or "Inventory", 0, nil, options.Collection) for _, row in ipairs(result.rows) do if not row.resolved then result.unresolved = result.unresolved + 1 end if row.icon == "" then result.missingIcons = result.missingIcons + 1 end end table.sort(result.rows, function(a, b) if a.category ~= b.category then return a.category < b.category end if a.name ~= b.name then return a.name:lower() < b.name:lower() end return a.key < b.key end) return result end function M.diff(before, after) local changes, old, current = {}, {}, {} for _, row in ipairs(before or {}) do old[row.key] = row end for _, row in ipairs(after or {}) do current[row.key] = true local delta = row.qty - (old[row.key] and old[row.key].qty or 0) if delta ~= 0 then table.insert(changes, { name = row.name, category = row.category, variant = row.variant, delta = delta }) end end for _, row in ipairs(before or {}) do if not current[row.key] then table.insert(changes, { name = row.name, category = row.category, variant = row.variant, delta = -row.qty }) end end table.sort(changes, function(a, b) return a.name < b.name end) return changes end function M.filter(rows, category, query, order) local filtered = {} query = clean(query):lower() for _, row in ipairs(rows) do local text = (row.name .. " " .. row.id .. " " .. row.category .. " " .. row.variant .. " " .. row.rarity .. " " .. table.concat(row.instances or {}, " ")):lower() if (category == "Semua" or category == row.category) and (query == "" or text:find(query, 1, true)) then table.insert(filtered, row) end end table.sort(filtered, function(a, b) if order == "Jumlah" and a.qty ~= b.qty then return a.qty > b.qty end if a.name:lower() ~= b.name:lower() then return a.name:lower() < b.name:lower() end return a.key < b.key end) return filtered end -- A passive copy of only the inventory protocol. Never writes to the game's Replion. function M.copyData(value, limit) local remaining, seen = limit or 200000, {} local function copy(child, depth) if type(child) ~= "table" then if type(child) == "string" or type(child) == "number" or type(child) == "boolean" then return child end return nil end if seen[child] then return seen[child] end if depth > 24 then error("Inventory payload terlalu dalam") end local result = {}; seen[child] = result for key, item in next, child do remaining = remaining - 1 if remaining < 0 then error("Inventory payload melebihi batas salinan") end if type(key) == "string" or type(key) == "number" then result[key] = copy(item, depth + 1) end end return result end local ok, result = pcall(copy, value, 0) if ok then return result end return nil, result end local function protocolPath(value) local parts = {} if type(value) == "string" then for key in value:gmatch("[^%.]+") do table.insert(parts, tonumber(key) or key) end elseif type(value) == "table" then for index = 1, #value do local key = value[index] if type(key) ~= "string" and type(key) ~= "number" then return nil end table.insert(parts, key) end else return nil end if #parts > 24 then return nil end if parts[1] == "Data" or parts[1] == "Profile" then table.remove(parts, 1) end if parts[1] ~= "Inventory" then return nil end return parts end local function pathName(parts) local names = {}; for _, part in ipairs(parts) do table.insert(names, tostring(part)) end return table.concat(names, ".") end local function setPath(root, path, value) local cursor = root for index = 1, #path - 1 do local key = path[index] if type(cursor[key]) ~= "table" then cursor[key] = {} end cursor = cursor[key] end cursor[path[#path]] = value end function M.eventStore() return { records = {}, excludedIds = {}, sequence = 0, accepted = 0, ignored = 0, rejected = 0 } end function M.observe(store, remote, args, options) options = options or {} local limit = options.MaxNodes or 200000 local function reject(message) store.rejected = store.rejected + 1; store.lastError = message return false end local function accept(record, label) store.sequence = store.sequence + 1; store.accepted = store.accepted + 1 record.sequence = store.sequence; record.capturedAt = os.time(); store.lastPath = label return true end if remote == "Removed" then local record = store.records[args[1]] if record then store.records[args[1]] = nil; return accept(record, "Removed") end return false end if remote == "Added" then local batch = type(args[1]) == "table" and (type(args[1][1]) == "table" and args[1] or { args[1] }) or {} local found = false for _, serialized in ipairs(batch) do local owns, scopeReason = true, nil if options.LocalUserId then owns, scopeReason = M.profileScope(serialized[3], options.LocalUserId, serialized[2], serialized[4] == "All", options.PersonalChannels) end local inventory = type(serialized[3]) == "table" and M.inventory(serialized[3], { { "Inventory" }, { "Data", "Inventory" }, { "Profile", "Inventory" } }) if not owns and serialized[1] ~= nil then store.excludedIds[serialized[1]] = true; store.records[serialized[1]] = nil if inventory then reject(scopeReason) end elseif type(inventory) == "table" and serialized[1] ~= nil then store.excludedIds[serialized[1]] = nil local value, err = M.copyData(inventory, limit) if err then reject(err) else local record = { id = serialized[1], channel = serialized[2], data = { Inventory = value }, complete = true, completePaths = {} } store.records[serialized[1]] = record; accept(record, "Added.Inventory"); found = true end end end return found end local path = protocolPath(remote == "ArrayUpdate" and args[3] or args[2]) -- Root Update can carry an entire inventory in its dictionary. local rootUpdate = remote == "Update" and args[3] == nil and type(args[2]) == "table" and args[2].Inventory if not path and (type(rootUpdate) == "table" or rootUpdate == "\0") then path = { "Inventory" } end if not path or args[1] == nil then store.ignored = store.ignored + 1; return false end if store.excludedIds[args[1]] then store.ignored = store.ignored + 1; return false end if remote ~= "Set" and remote ~= "Update" and remote ~= "ArrayUpdate" then return false end local id, label = args[1], pathName(path) local record = store.records[id] if not record then if options.MaxChannels and options.MaxChannels <= 0 then return reject("Channel limit") end local count = 0; for _ in pairs(store.records) do count = count + 1 end if count >= (options.MaxChannels or 16) then return reject("Terlalu banyak channel inventori") end record = { id = id, data = { Inventory = {} }, complete = false, completePaths = {} } end if remote == "Set" or rootUpdate then local value, err = M.copyData(rootUpdate or args[3], limit) if err then return reject(err) end if value == "\0" then value = nil end setPath(record.data, path, value) -- Replacing an ancestor invalidates assumptions about its previous descendants. for known in pairs(record.completePaths) do if known == label or known:sub(1, #label + 1) == label .. "." then record.completePaths[known] = nil end end if type(value) == "table" then record.completePaths[label] = true if #path == 1 then record.complete = true end elseif #path == 1 then record.complete = false end elseif remote == "Update" then if type(args[3]) ~= "table" then return reject("Update inventory tanpa dictionary") end local value, err = M.copyData(args[3], limit) if err then return reject(err) end local target = M.path(record.data, path) if type(target) ~= "table" then target = {}; setPath(record.data, path, target) end for key, child in pairs(value) do local targetKey = args[4] and (tonumber(key) or key) or key if child == "\0" then target[targetKey] = nil else target[targetKey] = child end end else local action = args[2] if action ~= "i" and action ~= "r" and action ~= "c" then return reject("ArrayUpdate action tidak dikenali") end local array = M.path(record.data, path) if type(array) ~= "table" then array = {}; setPath(record.data, path, array) end local trusted = record.complete or record.completePaths[label] == true if action == "c" then setPath(record.data, path, {}); record.completePaths[label] = true elseif action == "i" then local value, err = M.copyData(args[4], limit) if err then return reject(err) end store.lastItem = type(value) == "table" and value or nil local index = args[5] if index ~= nil and (type(index) ~= "number" or index % 1 ~= 0 or index < 1 or index > limit) then return reject("Index insert tidak valid") end if trusted then index = index or #array + 1 if index > #array + 1 then return reject("Index insert di luar snapshot") end table.insert(array, index, value) elseif index then local slots = {}; for key in pairs(array) do if type(key) == "number" and key >= index then table.insert(slots, key) end end table.sort(slots, function(a, b) return a > b end) for _, key in ipairs(slots) do array[key + 1] = array[key]; array[key] = nil end array[index] = value else -- The server did not send an index. Keep a UUID record without inventing its absolute slot. local uid = type(value) == "table" and first(value, uniqueFields) array[M.uuid(uid) or uid or ("observed:" .. tostring(store.sequence + 1))] = value end else local index = args[4] if index ~= nil and (type(index) ~= "number" or index % 1 ~= 0 or index < 1 or index > limit) then return reject("Index remove tidak valid") end if trusted then index = index or #array if index >= 1 and index <= #array then table.remove(array, index) end else local unindexed = false; for key in pairs(array) do if type(key) ~= "number" then unindexed = true; break end end if not index or unindexed then setPath(record.data, path, {}) record.warning = "Remove tanpa posisi yang dapat dipastikan: cache parsial dikosongkan." else array[index] = nil local slots = {}; for key in pairs(array) do if type(key) == "number" and key > index then table.insert(slots, key) end end table.sort(slots) for _, key in ipairs(slots) do array[key - 1] = array[key]; array[key] = nil end end end end end store.records[id] = record return accept(record, label) end function M.observedInventory(store) local selected for _, record in pairs(store.records) do if type(record.data.Inventory) == "table" and (not selected or (record.complete and not selected.complete) or (record.complete == selected.complete and record.sequence > selected.sequence)) then selected = record end end if selected then return selected.data.Inventory, selected end return nil end function M.withProfileAbilities(inventory, data, localUserId, catalog) if type(inventory) ~= "table" or type(data) ~= "table" then return inventory, 0 end if localUserId and not M.profileScope(data, localUserId, "Data", false, {"Data"}) then return inventory, 0 end local roots = {data, data.Data or false, data.Profile or false} local collections, seen = {}, {} for _, root in ipairs(roots) do local abilities = type(root) == "table" and root.Abilities if type(abilities) == "table" and type(abilities.Inventory) == "table" and not seen[abilities.Inventory] then seen[abilities.Inventory] = true; table.insert(collections, abilities.Inventory) end end if #collections == 0 then return inventory, 0 end local combined = {} -- Preserve existing ability collections and let UUID deduplication merge shared entries. local existing = M.normalize({Abilities = inventory.Abilities, Ability = inventory.Ability}, catalog, {CollectRecords = true}) if existing.truncated then return inventory, 0 end for _, record in ipairs(existing.items) do table.insert(combined, record) end local additions = 0 for _, collection in ipairs(collections) do for key, record in pairs(collection) do if type(record) == "table" and (itemRecord(record) or M.uuid(key)) then local owned = table.clone(record); owned.Type = "Abilities" if not first(owned, uniqueFields) and M.uuid(key) then owned.UUID = M.uuid(key) end table.insert(combined, owned); additions = additions + 1 end end end local view = table.clone(inventory); view.Abilities = combined; view.Ability = nil return view, additions end function M.categoryTotals(rows) local grouped, result = {}, {} for _, row in ipairs(rows or {}) do local entry = grouped[row.category] if not entry then entry = {category = row.category, quantity = 0, records = 0, groups = 0, unresolved = 0} grouped[row.category] = entry; table.insert(result, entry) end entry.quantity = entry.quantity + row.qty entry.records = entry.records + row.records; entry.groups = entry.groups + 1 if not row.resolved then entry.unresolved = entry.unresolved + 1 end end table.sort(result, function(a, b) return a.category < b.category end) return result end function M.equipmentFields(data, localUserId, rootPath, predicate) if type(data) ~= "table" then return {} end if localUserId and not M.profileScope(data, localUserId, "Data", false, {"Data"}) then return {} end local function preview(value, depth) if type(value) == "string" then return string.format("%q", value:sub(1, 160)) .. (#value > 160 and "..." or "") end if type(value) ~= "table" then return tostring(value) end if depth >= 3 then return "{...}" end local keys = {}; for key in pairs(value) do table.insert(keys, key) end table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) local parts = {} for index = 1, math.min(#keys, 8) do local key = keys[index] table.insert(parts, tostring(key) .. "=" .. preview(value[key], depth + 1)) end if #keys > 8 then table.insert(parts, "... (" .. #keys .. " field)") end return "{" .. table.concat(parts, ", ") .. "}" end local result, seen = {}, {} local function inspect(value, path, depth) if type(value) ~= "table" or seen[value] or depth > 3 then return end seen[value] = true for key, child in pairs(value) do if type(key) == "string" then local name = normalized(key) local relevant if predicate then relevant = predicate(name) else relevant = name:find("equipped", 1, true) or name:find("enchant", 1, true) or name == "equipment" or name == "loadout" or name == "equipped" or name == "activerod" or name == "activebait" or name == "activeability" or name == "abilities" or name == "ability" end if relevant then table.insert(result, path .. "." .. key .. " = " .. preview(child, 0)) end end end for _, field in ipairs({"Data", "Profile", "Equipment", "Equipped", "Loadout", "Inventory", "Metadata", "Meta", "Statistics", "Stats", "Wallet", "Currencies", "Currency"}) do if type(value[field]) == "table" then inspect(value[field], path .. "." .. field, depth + 1) end end end inspect(data, rootPath or "Profile", 0); table.sort(result) return result end function M.compactNumber(value) if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then return nil end for _, unit in ipairs({{1e9, "B"}, {1e6, "M"}, {1e3, "K"}}) do if math.abs(value) >= unit[1] then return string.format("%.2f%s", value / unit[1], unit[2]) end end return tostring(value) end function M.playerStats(data, localUserId) local result = {} data = type(data) == "table" and data or {} if localUserId and not M.profileScope(data, localUserId, "Data", false, {"Data"}) then data = {} end local roots = {{data = data, path = "Profile"}} for _, field in ipairs({"Data", "Profile"}) do if type(data[field]) == "table" then table.insert(roots, {data = data[field], path = field}) end end local candidates = { coins = {{"Coins"}, {"Coin"}, {"CoinBalance"}, {"Wallet", "Coins"}, {"Currencies", "Coins"}, {"Currency", "Coins"}}, caught = {{"Statistics", "FishCaught"}, {"Statistics", "TotalFishCaught"}, {"Stats", "FishCaught"}, {"TotalFishCaught"}, {"FishCaught"}, {"TotalCaught"}}, rarestFish = {{"Statistics", "RarestFishCaught"}, {"Statistics", "RarestFishOdds"}, {"Statistics", "RarestFishChanceDenominator"}, {"Statistics", "RarestFish"}, {"Stats", "RarestFish"}, {"RarestFishOdds"}, {"RarestFishChanceDenominator"}, {"RarestFish"}}, } for key, paths in pairs(candidates) do local slot = {status = "unknown", display = "Belum terbaca", source = "Field belum tersedia"} for _, root in ipairs(roots) do for _, path in ipairs(paths) do local raw = M.path(root.data, path) if raw ~= nil then local display, value, probability, oddsDenominator if key ~= "rarestFish" then local amount = (type(raw) == "number" or type(raw) == "string") and tonumber(raw) if amount and amount >= 0 and amount < math.huge and amount == amount then value = amount; display = M.compactNumber(amount) end elseif path[#path] == "RarestFishCaught" then -- This observed field stores a probability, not an item ID or denominator. if type(raw) == "number" and raw > 0 and raw <= 1 then local denominator = 1 / raw if denominator < math.huge then -- Reciprocal arithmetic can land just below an integer/unit boundary. -- Round only the display when that difference is floating-point noise. local displayDenominator = denominator local nearest = math.floor(denominator + 0.5) if math.abs(denominator - nearest) <= denominator * 1e-12 then displayDenominator = nearest end local formatted = M.compactNumber(displayDenominator) :gsub("%.00([KMB])$", "%1"):gsub("(%.[0-9])0([KMB])$", "%1%2") value = raw; probability = raw; oddsDenominator = denominator display = "1/" .. formatted end end elseif type(raw) == "number" and raw >= 0 and raw < math.huge and raw == raw then value = raw display = path[#path] == "RarestFishChanceDenominator" and raw > 0 and ("1/" .. M.compactNumber(raw)) or tostring(raw) elseif type(raw) == "string" and clean(raw) ~= "" then value = raw; display = clean(raw) elseif type(raw) == "table" then value = M.copyData(raw, 256) local name = first(raw, {"Name", "DisplayName", "FishName"}) local odds = first(raw, {"Odds", "OddsText", "ChanceText"}) local denominator = first(raw, {"ChanceDenominator", "OddsDenominator", "Denominator"}) if type(denominator) == "number" and denominator >= 1 and denominator < math.huge then odds = "1/" .. M.compactNumber(denominator) end if type(odds) == "string" then display = (type(name) == "string" and (name .. " | ") or "") .. odds elseif type(name) == "string" then display = name end end if display then slot = {status = "known", value = value, display = display, probability = probability, oddsDenominator = oddsDenominator, source = root.path .. "." .. table.concat(path, ".")} break end end end if slot.status == "known" then break end end result[key] = slot end result.fields = M.equipmentFields(data, localUserId, "Profile", function(name) return name == "coins" or name == "coin" or name == "coinbalance" or name:find("caught", 1, true) or name:find("rarest", 1, true) end) return result end local equipmentSlots = { rod = {category = "Fishing Rods", fields = {"EquippedRodUUID", "EquippedFishingRodUUID", "EquippedFishingRod", "EquippedRod", "EquippedRodId", "EquippedFishingRodId", "ActiveRod"}, short = {"FishingRod", "Rod", "FishingRods", "Rods", "Fishing Rods", "Fishing Rod"}}, bait = {category = "Baits", fields = {"EquippedBait", "EquippedBaitId", "ActiveBait"}, short = {"Bait", "Baits"}}, ability = {category = "Abilities", fields = {"EquippedAbility", "EquippedAbilities", "EquippedAbilityId", "ActiveAbility"}, short = {"Ability", "Abilities"}}, pet = {category = "Pets", fields = {"EquippedPetUUID", "EquippedPet", "EquippedPetId"}, short = {"Pet", "Pets"}}, potion = {category = "Potions", fields = {"EquippedPotions", "EquippedPotionUUID", "EquippedPotionId"}, short = {"Potion", "Potions"}}, } local enchantFields = {"Enchantments", "Enchants", "Enchantment", "Enchant", "EnchantId", "EnchantmentId"} function M.enchantNames(value, catalog) if value == nil then return nil end local names, seen = {}, {} local function add(item, level) if type(item) == "table" then level = item.Level or level item = item.Name or item.DisplayName or item.Id or item.EnchantId or item.EnchantmentId end if item == false or item == nil or item == 0 or item == "" or item == "\0" then return end if type(item) ~= "string" and type(item) ~= "number" then return end local text = clean(item) if normalized(text) == "none" or text == "0" then return end local name = catalog.enchants[tostring(item)] or (tonumber(item) and "Enchant #" .. text or text) if tonumber(level) and tonumber(level) > 0 then name = name .. " (Lv." .. tostring(level) .. ")" end if not seen[name] then seen[name] = true; table.insert(names, name) end end if type(value) == "table" then if value.Name or value.Id or value.EnchantId or value.EnchantmentId then add(value) elseif isArray(value) then for _, item in ipairs(value) do add(item) end else for key, item in pairs(value) do if item == true then add(key) elseif type(item) == "table" then add(item) elseif tonumber(item) and (catalog.enchants[tostring(key)] or type(key) == "string") then add(key, item) else add(item) end end end else add(value) end table.sort(names) return #names > 0 and table.concat(names, ", ") or "Tidak ada" end function M.enchantSlots(primary, secondary, catalog, hasPrimary, hasSecondary) local result = {first = "Belum terbaca", second = "Belum terbaca", firstKnown = false, secondKnown = false} local namedSlots = false local function set(index, value) result[index] = M.enchantNames(value, catalog) or "Belum terbaca" result[index .. "Known"] = value ~= nil end if hasPrimary then if type(primary) == "table" and not (primary.Name or primary.DisplayName or primary.Id or primary.EnchantId or primary.EnchantmentId) then if next(primary) == nil then set("first", false); set("second", false) elseif primary[1] ~= nil or primary[2] ~= nil then if primary[1] ~= nil then set("first", primary[1]) end if primary[2] ~= nil then set("second", primary[2]) end else local firstSlot = first(primary, {"Primary", "First", "Slot1"}) local secondSlot = first(primary, {"Secondary", "Second", "Slot2"}) if firstSlot ~= nil or secondSlot ~= nil then namedSlots = true if firstSlot ~= nil then set("first", firstSlot) end if secondSlot ~= nil then set("second", secondSlot) end else local count = 0; for _ in pairs(primary) do count = count + 1 end if count == 1 then set("first", primary) end end end else set("first", primary) end end if hasSecondary then set("second", secondary) end result.aggregate = hasPrimary and M.enchantNames(primary, catalog) or nil if namedSlots then local names = {} if result.firstKnown and result.first ~= "Tidak ada" then table.insert(names, result.first) end if result.secondKnown and result.second ~= "Tidak ada" then table.insert(names, result.second) end result.aggregate = #names > 0 and table.concat(names, ", ") or "Tidak ada" end local secondName = hasSecondary and M.enchantNames(secondary, catalog) or nil if secondName and secondName ~= "Tidak ada" then result.aggregate = result.aggregate and result.aggregate ~= "Tidak ada" and (result.aggregate .. ", " .. secondName) or secondName end result.aggregate = result.aggregate or (hasSecondary and "Tidak ada" or "Belum terbaca") return result end function M.equipment(data, inventory, catalog, localUserId, readCache) local result = {} if localUserId and type(data) == "table" then local owns = M.profileScope(data, localUserId, "Data", false, {"Data"}) if not owns then data = {} end end data = type(data) == "table" and data or {} inventory = M.withProfileAbilities(inventory or {}, data, localUserId, catalog) local sources = {{data = data, path = "Profile"}} for _, key in ipairs({"Data", "Profile"}) do if type(data[key]) == "table" then table.insert(sources, {data = data[key], path = key}) end end local roots = table.clone(sources) for _, root in ipairs(roots) do for _, key in ipairs({"Equipment", "Equipped", "Loadout", "EquippedItems", "Inventory"}) do if type(root.data[key]) == "table" then table.insert(sources, {data = root.data[key], path = root.path .. "." .. key, short = key ~= "Inventory"}) end end end local flat = readCache and readCache[inventory] or M.normalize(inventory or {}, catalog, {CollectRecords = true}) if readCache then readCache[inventory] = flat end local ownedByUUID = {} for _, record in ipairs(flat.items) do local uid = M.uuid(record.UUID) if uid then ownedByUUID[uid] = record end end local function sharedEquipped(category) local matches, seen = {}, {} for _, root in ipairs(roots) do local shared = root.data.EquippedItems if type(shared) == "table" then for key, reference in pairs(shared) do local uid = M.uuid(type(reference) == "table" and first(reference, uniqueFields) or reference) or (reference == true and M.uuid(key)) local owned = uid and ownedByUUID[uid] if owned and owned.Type == category and not seen[uid] then seen[uid] = true table.insert(matches, {uuid = uid, record = owned, path = root.path .. ".EquippedItems"}) end end end end return matches end local function resolve(value, category) if value == false or value == 0 or value == "" or value == "\0" then return {name = "Tidak ada", status = "none"} end local uuid = M.uuid(type(value) == "table" and first(value, uniqueFields) or value) local id = type(value) == "table" and first(value, idFields) or (not uuid and tonumber(value)) local name = type(value) == "table" and first(value, nameFields) or (type(value) == "string" and not uuid and not id and value) local matches = {} for _, record in ipairs(flat.items) do if record.Type == category and ((uuid and record.UUID == uuid) or (not uuid and id and tostring(record.Id) == tostring(id)) or (not uuid and not id and name and record.Name == name)) then table.insert(matches, record) end end local owned = #matches == 1 and matches[1] or nil local entry = M.lookup(catalog, id or (owned and owned.Id), name or (owned and owned.Name), category) local slot = {uuid = uuid or (owned and M.uuid(owned.UUID)), id = id or (owned and owned.Id), record = owned, reference = value, name = (entry and entry.name) or name or (owned and owned.Name) or (uuid and "UUID " .. uuid) or (id and category .. " #" .. tostring(id)) or "Belum terbaca", status = (entry or name or uuid or id) and "known" or "unknown"} -- A record supplied by the explicit equipped field belongs to that active slot. if type(value) == "table" and (id or name or uuid) then slot.equippedRecord = value end return slot end for key, config in pairs(equipmentSlots) do local value, proof for _, source in ipairs(sources) do local fields = source.short and config.short or config.fields for _, field in ipairs(fields) do if source.data[field] ~= nil then value = source.data[field]; proof = source.path .. "." .. field; break end end if proof then break end end if key == "ability" and value == nil then for _, root in ipairs(roots) do if type(root.data.Abilities) == "table" and root.data.Abilities.Equipped ~= nil then value = root.data.Abilities.Equipped; proof = root.path .. ".Abilities.Equipped"; break end end end if key == "rod" and value == nil then for _, root in ipairs(roots) do local uid = M.uuid(root.data.EquippedId) local owned = uid and ownedByUUID[uid] if M.category(root.data.EquippedType) == "Fishing Rods" and owned and owned.Type == "Fishing Rods" then value = uid; proof = root.path .. ".EquippedId (EquippedType Fishing Rods; UUID dimiliki)"; break end end end local shared = sharedEquipped(config.category) if value == nil and #shared > 0 then if key == "ability" then value = {}; for _, match in ipairs(shared) do table.insert(value, match.uuid) end proof = shared[1].path .. " (UUID dimiliki; kategori Abilities)" elseif #shared == 1 then value = shared[1].uuid; proof = shared[1].path .. " (UUID dimiliki; kategori " .. config.category .. ")" end elseif key == "rod" and #shared == 1 and value ~= false and value ~= 0 and value ~= "" and value ~= "\0" then local uid, id if type(value) == "table" then uid = M.uuid(first(value, uniqueFields)); id = first(value, idFields) else uid = M.uuid(value); id = tonumber(value) end if not uid and id and tostring(id) == tostring(shared[1].record.Id) then if type(value) == "table" then value = table.clone(value); value.UUID = shared[1].uuid else value = shared[1].uuid end proof = proof .. " + " .. shared[1].path .. " (UUID sesuai Id rod)" end end local slot if value == nil then slot = {name = "Belum terbaca", status = "unknown"} elseif type(value) == "table" and not itemRecord(value) then local names, resolvedItems = {}, {} for index, item in pairs(value) do local resolved = resolve(item == true and index or item, config.category) if resolved.status ~= "unknown" then table.insert(names, resolved.name); table.insert(resolvedItems, resolved) end end if key ~= "ability" and key ~= "potion" and #resolvedItems == 1 then slot = resolvedItems[1] elseif key ~= "ability" and key ~= "potion" and #resolvedItems > 1 then slot = {name = "Belum terbaca (beberapa kandidat)", status = "unknown"} else table.sort(names); slot = {name = #names > 0 and table.concat(names, ", ") or "Tidak ada", status = #names > 0 and "known" or "none", items = resolvedItems} end else slot = resolve(value, config.category) end slot.source = proof or "Field equip belum tersedia"; slot.category = config.category; result[key] = slot end local rod = result.rod local enchant, found, secondEnchant, secondFound for _, record in ipairs({rod.equippedRecord or false, rod.record or false}) do if type(record) == "table" then if not found then enchant = first(record, enchantFields) if enchant == nil then enchant = first(metadata(record), enchantFields) end if enchant ~= nil then found = true end end if not secondFound then secondEnchant = first(record, secondaryEnchantFields) if secondEnchant == nil then secondEnchant = first(metadata(record), secondaryEnchantFields) end if secondEnchant ~= nil then secondFound = true end end end end if not found then for _, root in ipairs(roots) do for _, field in ipairs({"EquippedRodEnchantments", "EquippedRodEnchants", "EquippedRodEnchant"}) do if root.data[field] ~= nil then enchant = root.data[field]; found = true; break end end if not found and rod.uuid then for _, field in ipairs({"RodEnchantments", "FishingRodEnchantments"}) do local map = root.data[field] if type(map) == "table" and map[rod.uuid] ~= nil then enchant = map[rod.uuid]; found = true; break end end end if found then break end end end local slots = M.enchantSlots(enchant, secondEnchant, catalog, found, secondFound) rod.enchants = rod.status == "none" and "Tidak ada rod aktif" or slots.aggregate rod.enchantKnown = found or secondFound or rod.status == "none" rod.enchant1 = rod.status == "none" and "Tidak ada rod aktif" or slots.first rod.enchant2 = rod.status == "none" and "Tidak ada rod aktif" or slots.second rod.enchant1Known = slots.firstKnown or rod.status == "none" rod.enchant2Known = slots.secondKnown or rod.status == "none" rod.enchantFields = {} for _, entry in ipairs({{rod.equippedRecord, "EquippedRod"}, {rod.record, "OwnedRod"}}) do for _, line in ipairs(M.equipmentFields(entry[1], nil, entry[2])) do table.insert(rod.enchantFields, line) end end return result end local function equipmentPath(value) local path = {} if type(value) == "string" then for key in value:gmatch("[^%.]+") do table.insert(path, tonumber(key) or key) end elseif type(value) == "table" then for _, key in ipairs(value) do if type(key) ~= "string" and type(key) ~= "number" then return nil end; table.insert(path, key) end else return nil end if path[1] == "Data" or path[1] == "Profile" then table.remove(path, 1) end if #path > 16 then return nil end local key = path[1] == "Inventory" and path[2] or path[1] if key == "Abilities" then local offset = path[1] == "Inventory" and 2 or 1 if #path == offset or path[offset + 1] == "Equipped" or path[offset + 1] == "Inventory" or path[offset + 1] == "Active" then return path end end if key == "Equipment" or key == "Equipped" or key == "Loadout" or key == "EquippedItems" or key == "EquippedId" or key == "EquippedType" or key == "RodEnchantments" or key == "FishingRodEnchantments" or tostring(key):match("^EquippedRodEnchant") then return path end for _, config in pairs(equipmentSlots) do if table.find(config.fields, key) then return path end end return nil end function M.isEquipmentPath(value) return equipmentPath(value) ~= nil end function M.tradeChoices(inventory, catalog) local flat = M.normalize(inventory or {}, catalog, {CollectRecords = true}) local byKey, canonicalRows = {}, {} for _, row in ipairs(flat.rows) do canonicalRows[row.key] = row end for _, record in ipairs(flat.items) do local row = canonicalRows[record.GroupKey] if row then local facts = M.tradeEligibility(record, M.lookup(catalog, record.Id, record.Name, record.Type)) local trade = byKey[row.key] or {verifiedUnits = 0, unknownUnits = 0, notTradableUnits = 0} local field = facts.status == "tradable" and "verifiedUnits" or facts.status == "unknown" and "unknownUnits" or "notTradableUnits" trade[field] = trade[field] + record.Quantity trade.rap = facts.rap or trade.rap byKey[row.key] = trade end end local result = {} for _, row in ipairs(flat.rows) do row.trade = byKey[row.key] or {verifiedUnits = 0, unknownUnits = 0, notTradableUnits = 0} if #row.instances > 0 and row.trade.verifiedUnits + row.trade.unknownUnits > 0 then table.insert(result, row) end end return result end function M.tradePlan(inventory, catalog, requests, context) context = context or {} local localId, targetId = tonumber(context.localUserId), tonumber(context.targetUserId) if not localId or not targetId or localId <= 0 or targetId <= 0 or localId % 1 ~= 0 or targetId % 1 ~= 0 or localId == targetId then return nil, "Target player tidak valid" end if context.fullSnapshot ~= true or context.catalogReady ~= true then return nil, "Tunggu snapshot penuh dan katalog selesai" end if type(requests) ~= "table" or #requests == 0 or #requests > 1000 then return nil, "Pilih item dan jumlah; maksimal 1000 pilihan" end local flat = M.normalize(inventory, catalog, {CollectRecords = true}) if flat.truncated then return nil, "Snapshot terpotong" end local groups, canonicalRows = {}, {} for _, row in ipairs(flat.rows) do canonicalRows[row.key] = row end for _, record in ipairs(flat.items) do local uuid = M.uuid(record.UUID) local meta = metadata(record) local locked = record.Locked == true or record.TradeLocked == true or meta.Locked == true or meta.TradeLocked == true local qty = tonumber(record.Quantity) local eligibility = M.tradeEligibility(record, M.lookup(catalog, record.Id, record.Name, record.Type)) if uuid and qty and qty > 0 and qty % 1 == 0 and not locked and eligibility.status ~= "notTradable" then local row = canonicalRows[record.GroupKey] if row then groups[row.key] = groups[row.key] or {} table.insert(groups[row.key], {uuid = uuid, id = record.Id, name = M.displayItemName(row), category = record.Type, mutation = row.mutation, available = qty, sourceQuantity = qty, trade = eligibility}) end end end for _, group in pairs(groups) do table.sort(group, function(a, b) return a.uuid < b.uuid end) end local plan = {localUserId = localId, targetUserId = targetId, units = {}, batches = {}, total = 0, maxSlots = 20, quantityPerSlot = 1, requiresUnitSelection = false, unverifiedEligibility = false, status = "planned", requests = M.copyData(requests, 5000)} for _, request in ipairs(requests) do if type(request) ~= "table" or type(request.key) ~= "string" then return nil, "Pilihan item tidak valid" end local wanted = tonumber(request.quantity) if not wanted or wanted <= 0 or wanted % 1 ~= 0 or wanted > 1000 or plan.total + wanted > 1000 then return nil, "Jumlah harus bilangan bulat 1–1000; maksimal 1000 unit per rencana" end local remaining = wanted for _, source in ipairs(groups[request.key] or {}) do local take = math.min(remaining, source.available) for _ = 1, take do local sourceIndex = source.sourceQuantity - source.available + 1 table.insert(plan.units, {sourceUUID = source.uuid, id = source.id, name = source.name, category = source.category, key = request.key, mutation = source.mutation, quantity = 1, sourceQuantity = source.sourceQuantity, sourceUnitIndex = sourceIndex, tradeStatus = source.trade.status, rap = source.trade.rap, tradeSource = source.trade.source, requiresUnitSelection = source.sourceQuantity > 1}) source.available = source.available - 1 end remaining = remaining - take if take > 0 and source.sourceQuantity > 1 then plan.requiresUnitSelection = true end if take > 0 and source.trade.status ~= "tradable" then plan.unverifiedEligibility = true end if remaining == 0 then break end end if remaining > 0 then return nil, "Unit tidak cukup, dikunci, atau tidak tradable: kurang " .. remaining end plan.total = plan.total + wanted end for index, unit in ipairs(plan.units) do local batch = math.floor((index - 1) / 20) + 1 plan.batches[batch] = plan.batches[batch] or {index = batch, slots = {}, targetUserId = targetId, status = "awaitingInvitationAcceptance"} table.insert(plan.batches[batch].slots, unit) end return plan end function M.tradeJournal(localUserId, targetUserId, plan) return {localUserId = localUserId, targetUserId = targetUserId, recording = false, targetAcceptance = "unverified", completion = "unverified"} end function M.tradeExecutionBatches(plan) local batches, latest = {}, {} for _, unit in ipairs(plan.units or {}) do local index = latest[unit.sourceUUID] and latest[unit.sourceUUID] + 1 or 1 while batches[index] and #batches[index].slots >= 20 do index = index + 1 end batches[index] = batches[index] or {index = index, slots = {}, targetUserId = plan.targetUserId, status = "pending"} table.insert(batches[index].slots, unit); latest[unit.sourceUUID] = index end return batches end function M.tradeOfferMatches(summary, slots, requireUnitQuantity) if not summary or summary.scopeMatches ~= true then return false, "Peserta sesi tidak cocok" end if summary.localTokens ~= 0 or summary.targetTokens ~= 0 then return false, "Offer token berubah" end local own, other = summary.localOffer, summary.targetOffer if not own or not other or not own.itemsAvailable or not other.itemsAvailable or own.truncated or other.truncated or own.unmapped ~= 0 or other.unmapped ~= 0 then return false, "Offer belum dapat diperiksa lengkap" end if other.cards ~= 0 then return false, "Offer target berubah; rencana ini untuk mengirim item" end local expected, seen = {}, {} for _, slot in ipairs(slots) do if expected[slot.sourceUUID] then return false, "UUID berulang dalam satu sesi" end expected[slot.sourceUUID] = slot end for _, card in ipairs(own.items) do local slot = expected[card.sourceUUID] if not slot or seen[card.sourceUUID] or card.itemType ~= (slot.itemType or slot.category) then return false, "UUID/jenis offer berbeda dari rencana" end seen[card.sourceUUID] = true if requireUnitQuantity and card.quantity ~= 1 and not (card.quantity == "unverified" and slot.sourceQuantity == 1) then return false, "Jumlah offer stack belum terbukti 1 unit; konfirmasi dihentikan" end end if own.cards ~= #slots then return false, "Jumlah kartu offer berbeda dari rencana" end return true end function M.tradeSessionSummary(data, localUserId, targetUserId, channel) -- Shape observed in the supplied TradeController, not the personal inventory channel. local localId, targetId = tonumber(localUserId), tonumber(targetUserId) if not localId or not targetId or localId <= 0 or targetId <= 0 or localId % 1 ~= 0 or targetId % 1 ~= 0 or localId == targetId then return nil, "Identitas sesi tidak valid" end if type(channel) ~= "string" or channel == "" then return nil, "Channel sesi belum diketahui" end if type(data) ~= "table" or type(data.PlayerList) ~= "table" or type(data.Players) ~= "table" then return nil, "Bentuk sesi trade belum tersedia" end local seen, count = {}, 0 for key, participant in pairs(data.PlayerList) do if key ~= 1 and key ~= 2 then return nil, "PlayerList bukan dua peserta" end local ok, id = pcall(function() return tonumber(participant.UserId) end) if not ok or (id ~= localId and id ~= targetId) or seen[id] then return nil, "Peserta sesi tidak cocok" end count = count + 1; seen[id] = true end if count ~= 2 or not seen[localId] or not seen[targetId] then return nil, "Peserta sesi tidak lengkap" end local localOffer, targetOffer = data.Players[tostring(localId)], data.Players[tostring(targetId)] if type(localOffer) ~= "table" or type(targetOffer) ~= "table" then return nil, "Offer peserta belum tersedia" end local playerCount = 0 for key in pairs(data.Players) do if key ~= tostring(localId) and key ~= tostring(targetId) then return nil, "Offer memuat peserta lain" end playerCount = playerCount + 1 end if playerCount ~= 2 then return nil, "Offer peserta tidak lengkap" end local function boolean(value) if type(value) == "boolean" then return value end; return "unverified" end local function number(value) if type(value) == "number" and value == value and value >= 0 and value < math.huge then return value end return "unverified" end local function items(offer) local result = {items = {}, cards = 0, itemsAvailable = type(offer.Items) == "table", unmapped = 0} local visited = 0 for itemType, records in pairs(type(offer.Items) == "table" and offer.Items or {}) do if type(itemType) == "string" and type(records) == "table" then for _, record in pairs(records) do visited = visited + 1 if visited > 40 then result.truncated = true; break end local uuid = type(record) == "table" and M.uuid(record.UUID) if uuid then local quantity = number(record.Quantity) if quantity == 0 then quantity = "unverified" end table.insert(result.items, {itemType = itemType, sourceUUID = uuid, quantity = quantity}) else result.unmapped = result.unmapped + 1 end end else result.unmapped = result.unmapped + 1 end if visited > 40 then break end end table.sort(result.items, function(a, b) if a.itemType ~= b.itemType then return a.itemType < b.itemType end return a.sourceUUID < b.sourceUUID end) result.cards = #result.items return result end local summary = {channel = channel, localUserId = localId, targetUserId = targetId, scopeMatches = true, source = "Replion trade session", completion = "unverified", playersReady = boolean(data.PlayersReady), localReady = boolean(localOffer.IsReady), targetReady = boolean(targetOffer.IsReady), localConfirmed = boolean(localOffer.IsConfirmed), targetConfirmed = boolean(targetOffer.IsConfirmed), lastModifiedTime = number(data.LastModifiedTime), tradeConfirmTime = number(data.TradeConfirmTime), localTokens = number(localOffer.Tokens), targetTokens = number(targetOffer.Tokens), localOffer = items(localOffer), targetOffer = items(targetOffer)} summary.stage = "unverified" if summary.localConfirmed == true and summary.targetConfirmed == true then summary.stage = type(summary.tradeConfirmTime) == "number" and summary.tradeConfirmTime > 0 and "confirmedCountdown" or "awaitingServerCompletion" elseif summary.localConfirmed == true and summary.targetConfirmed == false then summary.stage = "awaitingTargetConfirmation" elseif summary.targetConfirmed == true and summary.localConfirmed == false then summary.stage = "awaitingLocalConfirmation" elseif summary.playersReady == true then summary.stage = "awaitingConfirmation" elseif summary.localReady == true and summary.targetReady == false then summary.stage = "awaitingTargetReady" elseif summary.targetReady == true and summary.localReady == false then summary.stage = "awaitingLocalReady" elseif summary.localReady == false and summary.targetReady == false then summary.stage = "awaitingReady" end return summary end function M.tradeInventoryQuantities(inventory, catalog) local quantities = {} for _, record in ipairs(M.normalize(inventory or {}, catalog, {CollectRecords = true}).items) do local uuid = M.uuid(record.UUID) if uuid then quantities[uuid] = record.Quantity end end return quantities end function M.tradeInventoryDelta(before, after) local result = {} for uuid, quantity in pairs(before or {}) do local change = (after and after[uuid] or 0) - quantity if change ~= 0 then table.insert(result, {uuid = uuid, delta = change}) end end for uuid, quantity in pairs(after or {}) do if not before or before[uuid] == nil then table.insert(result, {uuid = uuid, delta = quantity}) end end table.sort(result, function(a, b) return a.uuid < b.uuid end) return result -- A local delta alone never proves a completed trade or recipient ownership. end function M.equipmentEvents() return {profiles = {}, excludedIds = {}, accepted = 0} end function M.observeEquipment(store, remote, args, options) if remote == "Added" then local batch = type(args[1]) == "table" and (type(args[1][1]) == "table" and args[1] or {args[1]}) or {} for _, serialized in ipairs(batch) do if serialized[1] ~= nil then local owns, proof = M.profileScope(serialized[3], options.LocalUserId, serialized[2], serialized[4] == "All", options.PersonalChannels) if owns then local data = M.copyData(serialized[3], options.MaxNodes) if data then store.profiles[serialized[1]] = {data = data, verified = true, scopeReason = proof}; store.excludedIds[serialized[1]] = nil; store.accepted = store.accepted + 1 end else store.excludedIds[serialized[1]] = true; store.profiles[serialized[1]] = nil end end end return end if remote == "Removed" then store.profiles[args[1]] = nil; return end if args[1] == nil or store.excludedIds[args[1]] then return end local path = equipmentPath(remote == "ArrayUpdate" and args[3] or args[2]) if not path then if remote == "Update" and args[3] == nil and type(args[2]) == "table" then for key, value in pairs(args[2]) do if equipmentPath({key}) then M.observeEquipment(store, "Set", {args[1], {key}, value}, options) end end end return end local profile = store.profiles[args[1]] or {data = {}, verified = false} if not store.profiles[args[1]] then local count = 0; for _ in pairs(store.profiles) do count = count + 1 end if count >= 16 then return end end if remote == "Set" then local value, err = M.copyData(args[3], options.MaxNodes); if err then return end if value == "\0" then value = false end setPath(profile.data, path, value) elseif remote == "Update" and type(args[3]) == "table" then local target = M.path(profile.data, path) if type(target) ~= "table" then target = {}; setPath(profile.data, path, target) end local value = M.copyData(args[3], options.MaxNodes); if not value then return end for key, child in pairs(value) do if child == "\0" then target[key] = false else target[key] = child end end elseif remote == "ArrayUpdate" then local array = M.path(profile.data, path) if type(array) ~= "table" then return end -- Unknown prior array positions must not be invented. if args[2] == "c" then setPath(profile.data, path, {}) elseif args[2] == "i" then local index = args[5] or #array + 1 if type(index) ~= "number" or index % 1 ~= 0 or index < 1 or index > #array + 1 then return end table.insert(array, index, M.copyData(args[4], options.MaxNodes)) elseif args[2] == "r" then local index = args[4] or #array if type(index) ~= "number" or index % 1 ~= 0 or index < 1 or index > #array then return end table.remove(array, index) else return end else return end store.profiles[args[1]] = profile; store.accepted = store.accepted + 1 end -- GUI visibility is never evidence of removal. Keep an account-bound bag independently of widgets. function M.bagLedger(userId) return {userId = tonumber(userId), groups = {}, uuids = {}, collections = {}, unresolvedRemovals = 0, authoritativeCategories = {}, revision = 0, capturedAt = 0} end local function bagSignature(record, catalog) local entry = M.lookup(catalog, record.Id, record.Name, record.Type) local variants = M.itemVariants(record, catalog) return M.category(entry and entry.category or record.Type) .. "\0" .. tostring(entry and entry.id or record.Id or record.Name) .. "\0" .. variants.variant .. "\0" .. variants.status end function M.bagReindex(ledger, catalog) local groups, keys = {}, {} for oldKey, group in pairs(ledger.groups) do group.data.Type = M.category(group.data.Type) local signature = bagSignature(group.data, catalog) local weight = tonumber(group.data.Weight or metadata(group.data).Weight) local key = signature .. "\0" .. tostring(weight or "") keys[oldKey] = key; group.key = key; group.signature = signature if groups[key] then groups[key].count = math.max(groups[key].count, group.count) groups[key].deleted = math.max(groups[key].deleted, group.deleted) else groups[key] = group end end ledger.groups = groups for _, member in pairs(ledger.uuids) do member.key = keys[member.key] or member.key; member.data.Type = M.category(member.data.Type) end local claimed = {} for _, member in pairs(ledger.uuids) do if not member.deleted then claimed[member.key] = (claimed[member.key] or 0) + member.qty end end for key, quantity in pairs(claimed) do if groups[key] then groups[key].count = math.max(groups[key].count, quantity) end end local categories = {} for category in pairs(ledger.authoritativeCategories or {}) do categories[M.category(category)] = true end ledger.authoritativeCategories = categories for collection, known in pairs(ledger.collections) do local mapped = {}; for category in pairs(known) do mapped[M.category(category)] = true end ledger.collections[collection] = mapped end end local function bagGroup(ledger, record, catalog) local signature = bagSignature(record, catalog) local weight = tonumber(record.Weight or metadata(record).Weight) local key = signature .. "\0" .. tostring(weight or "") local match, matches = nil, 0 for _, group in pairs(ledger.groups) do local other = tonumber(group.data.Weight or metadata(group.data).Weight) local resolution = tonumber(metadata(group.data).WeightResolution) or 0 if group.signature == signature and ((weight == nil and other == nil) or (weight and other and math.abs(weight - other) <= resolution / 2 + 0.000001)) then match = group; matches = matches + 1 end end if matches == 1 then return match end local group = ledger.groups[key] if not group then group = {key = key, signature = signature, data = M.copyData(record), count = 0, deleted = 0} ledger.groups[key] = group end return group end local function bagRecords(inventory, catalog) return M.normalize(inventory, catalog, {CollectRecords = true}) end function M.bagMergeGui(ledger, records, catalog, userId, now) if ledger.userId ~= tonumber(userId) then return false, "UserId cache berbeda" end local counts, samples = {}, {} local flat = bagRecords({Items = records}, catalog) if flat.truncated then return false, "GUI payload terpotong" end for _, record in ipairs(flat.items) do if not ledger.fullSnapshot and not ledger.authoritativeCategories[record.Type] then local group = bagGroup(ledger, record, catalog) counts[group.key] = (counts[group.key] or 0) + record.Quantity; samples[group.key] = record local uid = M.uuid(record.UUID) if uid and not ledger.uuids[uid] then ledger.uuids[uid] = {key = group.key, data = record, qty = record.Quantity} end end end local changed = false for key, count in pairs(counts) do local group = ledger.groups[key] local quantity = math.max(group.count, count - group.deleted) if quantity ~= group.count then changed = true; group.count = quantity end -- Display enrichments (icons/mutations) do not change identity or imply deletion. group.data = samples[key] end if changed then ledger.revision = ledger.revision + 1; ledger.capturedAt = now or os.time() end return changed end function M.bagUpsert(ledger, record, catalog, inserted) local flat = bagRecords({Items = {record}}, catalog) local value = flat.items[1]; if not value then return false end local uid = M.uuid(value.UUID); if not uid then return false end local existing = ledger.uuids[uid] if existing and existing.deleted and not inserted then return false end local group = bagGroup(ledger, value, catalog) if existing and not existing.deleted then local old = ledger.groups[existing.key] if old then old.count = math.max(0, old.count - existing.qty) if old ~= group then old.deleted = old.deleted + existing.qty end end group.count = group.count + value.Quantity elseif inserted then group.count = group.count + value.Quantity else -- A UUID learned for an existing item replaces anonymous coverage, not an extra item. local claimed = 0 for _, member in pairs(ledger.uuids) do if not member.deleted and member.key == group.key then claimed = claimed + member.qty end end group.count = math.max(group.count, claimed + value.Quantity) end ledger.uuids[uid] = {key = group.key, data = value, qty = value.Quantity} ledger.revision = ledger.revision + 1; ledger.capturedAt = os.time() return true end function M.bagRemove(ledger, uid) uid = M.uuid(uid); local member = uid and ledger.uuids[uid] if not member or member.deleted then return false end local group = ledger.groups[member.key] if group then group.count = math.max(0, group.count - member.qty); group.deleted = group.deleted + member.qty end member.deleted = true ledger.revision = ledger.revision + 1; ledger.capturedAt = os.time() return true end function M.bagInventory(ledger) local records, claimed = {}, {} for _, member in pairs(ledger.uuids) do if not member.deleted then table.insert(records, member.data); claimed[member.key] = (claimed[member.key] or 0) + member.qty end end for key, group in pairs(ledger.groups) do local anonymous = group.count - (claimed[key] or 0) if anonymous > 0 then local record = M.copyData(group.data); record.UUID = nil; record.Quantity = anonymous record.UUIDUnavailable = true; table.insert(records, record) end end return {Items = records} end function M.bagReplace(ledger, inventory, catalog, collection) local flat = bagRecords(inventory, catalog) if flat.truncated then return false end local categories = ledger.collections[collection] or {} for _, record in ipairs(flat.items) do categories[record.Type] = true end if collection ~= "Inventory.Items" then local category = collection:match("^Inventory%.([^%.]+)$") if category then categories[M.category(category)] = true end end if collection == "Inventory" then ledger.groups = {}; ledger.uuids = {}; ledger.collections = {}; ledger.authoritativeCategories = {}; ledger.fullSnapshot = false end for category in pairs(categories) do ledger.authoritativeCategories[category] = nil end for key, group in pairs(ledger.groups) do if categories[group.data.Type] then ledger.groups[key] = nil for uid, member in pairs(ledger.uuids) do if member.key == key then ledger.uuids[uid] = nil end end end end ledger.collections[collection] = categories M.bagMergeGui(ledger, flat.items, catalog, ledger.userId) for category in pairs(categories) do ledger.authoritativeCategories[category] = true end if collection == "Inventory" then ledger.fullSnapshot = true end ledger.revision = ledger.revision + 1; ledger.capturedAt = os.time() return true end function M.observeBag(store, ledger, remote, args, options, catalog) if tonumber(options.LocalUserId) ~= ledger.userId then return false end local path = protocolPath(remote == "ArrayUpdate" and args[3] or args[2]) local before = store.records[args[1]] local oldItem, itemPath if path and before then for length = 1, #path do local prefix = {}; for index = 1, length do prefix[index] = path[index] end local value = M.path(before.data, prefix) if itemRecord(value) then oldItem = M.copyData(value); itemPath = prefix; break end end if remote == "ArrayUpdate" and args[2] == "r" then local array = M.path(before.data, path) local index = args[4] or ((before.complete or before.completePaths[pathName(path)]) and type(array) == "table" and #array) oldItem = type(array) == "table" and index and M.copyData(array[index]) or nil end end local changed = M.observe(store, remote, args, options) if not changed then return false end if remote == "Removed" then return true end -- Replication lifecycle is not sale/trade evidence. if remote ~= "Added" and ledger.channelId and ledger.channelId ~= args[1] then return true end local _, record = M.observedInventory(store) if not record then return true end if ledger.channelId and ledger.channelId ~= record.id then return true end ledger.channelId = record.id if record.complete then M.bagReplace(ledger, record.data.Inventory, catalog, "Inventory"); return true end if not path then return true end -- An established collection snapshot remains authoritative through nested updates/removals. for length = 1, math.min(2, #path) do local prefix = {}; for index = 1, length do prefix[index] = path[index] end local known = pathName(prefix) if record.completePaths[known] then local value = M.path(record.data, prefix) if type(value) == "table" then M.bagReplace(ledger, length == 1 and value or {[prefix[2]] = value}, catalog, known) return true end end end local label = pathName(path) if (remote == "Set" or (remote == "ArrayUpdate" and args[2] == "c")) and #path <= 2 and record.completePaths[label] then local value = M.path(record.data, path) M.bagReplace(ledger, #path == 1 and value or {[path[2]] = value}, catalog, label) elseif remote == "ArrayUpdate" and args[2] == "i" then M.bagUpsert(ledger, args[4], catalog, true) local row = bagRecords({Items = {args[4]}}, catalog).items[1] if row then ledger.collections[label] = ledger.collections[label] or {}; ledger.collections[label][row.Type] = true end elseif remote == "ArrayUpdate" and args[2] == "r" then if not (oldItem and M.bagRemove(ledger, oldItem.UUID)) then ledger.unresolvedRemovals = ledger.unresolvedRemovals + 1 end elseif itemPath then local value = M.path(record.data, itemPath) if type(value) == "table" then M.bagUpsert(ledger, value, catalog, false) elseif oldItem then M.bagRemove(ledger, oldItem.UUID) end elseif remote == "Set" and M.uuid(path[#path]) then local value = M.path(record.data, path) if type(value) == "table" then value = M.copyData(value); value.UUID = value.UUID or path[#path]; M.bagUpsert(ledger, value, catalog, false) elseif not M.bagRemove(ledger, path[#path]) then ledger.unresolvedRemovals = ledger.unresolvedRemovals + 1 end end return true end return M end)() -- The Luau CLI can require this file to exercise normalization without Roblox. if not game then return Core end local CONFIG = { RefreshSeconds = 15, -- Event listeners still refresh changed inventory after 0.15–0.3 seconds. PageSize = 25, ReplionChannels = { "Data", "PlayerData", "Profile", "Inventory", "Backpack" }, -- The supplied scanner trace confirms ArrayUpdate on Inventory.Items. -- Read its parent too so rods/baits/other sibling collections remain included. InventoryPaths = { { "Inventory" }, { "Data", "Inventory" }, { "Profile", "Inventory" }, { "Inventory", "Items" } }, ItemPath = { "Items" }, ReplionPackage = "ytrev_replion@2.0.0-rc.3", ObserveInventoryEvents = true, ReadInventoryGui = true, -- Automatic retained-widget fallback at the user-confirmed native bag path. AutoInventoryGuiPaths = {{"Inventory", "Main", "Content", "Pages", "Inventory2", "Main", "Inventory"}}, MaxGuiInstances = 40000, CatalogPaths = Core.catalogPaths(), TierPaths = { { "Tiers" }, { "Modules", "Tiers" } }, VariantPaths = { { "Mutations" }, { "Variants" }, { "Modules", "Mutations" }, { "Modules", "Variants" }, { "Shared", "Mutations" } }, EnchantPaths = {{"Enchantments"}, {"Enchants"}, {"Modules", "Enchantments"}, {"Modules", "Enchants"}}, RequireTimeout = 1, ReplionRequireTimeout = 5, MaxInventoryNodes = 200000, -- Optional function returning inventoryTable, sourceLabel. It must only read local data. InventoryReader = nil, } local env = (type(getgenv) == "function" and getgenv()) or _G -- Stop any recorder left by a previous execute. A fresh client session is still -- needed to remove a previously installed executor hook completely. if type(env.RENN_EQUIP_CAPTURE) == "table" then env.RENN_EQUIP_CAPTURE.listener = nil end local previous = env.RENN_STATS local legacy = env.RENN_INVENTORY if type(legacy) == "table" and type(legacy.Close) == "function" then pcall(legacy.Close) end local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local HttpService = game:GetService("HttpService") local UserInputService = game:GetService("UserInputService") local player = Players.LocalPlayer while not player do task.wait(); player = Players.LocalPlayer end local playerGui = player:WaitForChild("PlayerGui") local reuse = type(previous) == "table" and type(previous.bag) == "table" and previous.bag.userId == player.UserId and previous.playerGui == playerGui and type(previous.bag.authoritativeCategories) == "table" if type(previous) == "table" and type(previous.Close) == "function" then pcall(previous.Close) end local state = { alive = true, paused = false, busy = false, queued = false, catalog = Core.catalog(), catalogReady = false, catalogFailures = 0, catalogPhase = "Menunggu discovery client", catalogModules = 0, catalogLabelFailures = 0, connections = {}, tasks = {}, sourceConnections = {}, replion = nil, client = nil, clients = {}, discoveryNotes = {}, rows = {}, changes = {}, baselineSource = nil, snapshot = nil, source = "Menunggu data", category = "Semua", order = "Nama", page = 1, query = "", renderRevision = 0, observed = Core.eventStore(), equipmentObserved = Core.equipmentEvents(), remoteCount = 0, partial = true, cacheReaderProbes = {}, bag = Core.bagLedger(player.UserId), playerGui = playerGui, revision = 0, statsRevision = 0, } if reuse then state.bag = previous.bag; Core.bagReindex(state.bag, state.catalog) state.observed = previous.observed or state.observed state.equipmentObserved = previous.equipmentObserved or state.equipmentObserved state.reusedCache = true end local function connect(signal, callback, bucket) local connection = signal:Connect(callback) table.insert(bucket or state.connections, connection) return connection end local function spawnTask(callback) local thread = task.defer(function() local running = coroutine.running() state.tasks[running] = true local ok, err = pcall(callback) state.tasks[running] = nil if not ok and state.alive then state.lastError = tostring(err) end end) state.tasks[thread] = true return thread end local function boundedRequire(module, loader, timeout) local done, ok, result = false, false, nil local thread = spawnTask(function() ok, result = pcall(loader or require, module) done = true end) local deadline = os.clock() + (timeout or CONFIG.RequireTimeout) while state.alive and not done and os.clock() < deadline do task.wait() end if not done then pcall(task.cancel, thread) state.tasks[thread] = nil return false, "Module timeout: " .. module.Name end return ok, result end local function instancePath(path) local current = ReplicatedStorage for _, name in ipairs(path) do current = current and current:FindFirstChild(name) end return current end local refresh, render, status, refreshCategories, discoverClient state.GetDiagnostics = function() return (state.diagnostics or "Menunggu penemuan data") .. (state.lastError and ("\nLast error: " .. state.lastError) or "") .. "\n" .. Core.catalogDiagnostics(state, os.clock()) end -- Headless: status lives in memory and is delivered to the authenticated website. status = function(message) state.status = tostring(message) end render = function() end refreshCategories = function() end state.renderInfo = function() end state.renderLoading = function() end state.renderEquipment = function() end state.RestorePanel = function() end state.HideForTradeRecording = function() end state.SetRecorderControls = function() end state.Close = function() if not state.alive then return end if state.StopAutoTrade then pcall(state.StopAutoTrade, "Collector ditutup") end state.alive = false local running = coroutine.running() for thread in pairs(state.tasks) do if thread ~= running then pcall(task.cancel, thread) end end for _, bucket in ipairs({ state.connections, state.sourceConnections, state.tradeConnections or {} }) do for _, connection in ipairs(bucket) do pcall(function() connection:Disconnect() end) end table.clear(bucket) end if state.clearViewConnections then state.clearViewConnections() end if state.closePicker then state.closePicker() end if state.gui then state.gui:Destroy() end if state.recorderGui then state.recorderGui:Destroy() end if env.RENN_STATS == state then env.RENN_STATS = nil end end env.RENN_STATS = state -- Reader and catalog discovery are below the GUI to keep initial loading visible. local function describeShape(value) if type(value) ~= "table" then return type(value) end local keys = {} for key, child in pairs(value) do if #keys >= 24 then table.insert(keys, "..."); break end table.insert(keys, tostring(key) .. ":" .. type(child)) end table.sort(keys) return "{" .. table.concat(keys, ", ") .. "}" end local function guiScope(node, selected, confirmed) if not node or not node:IsA("ScrollingFrame") then return false, "Area tas harus ScrollingFrame" end local localGui = node:IsDescendantOf(playerGui) local visible, blocked, cursor, hiddenPath = true, false, node, nil while cursor and cursor ~= playerGui do if cursor == state.gui or cursor.Name == "RennInventoryScanner" then blocked = true end if (cursor:IsA("GuiObject") and not cursor.Visible) or (cursor:IsA("ScreenGui") and not cursor.Enabled) then visible = false; hiddenPath = hiddenPath or cursor:GetFullName() end if Core.blockedGuiName(cursor.Name) then blocked = true end cursor = cursor.Parent end local allowed, reason = Core.guiScope({ localPlayerGui = localGui, selectedList = selected, visible = visible, blocked = blocked, confirmedList = confirmed }) return allowed, reason, hiddenPath end state.SelectInventoryGui = function(node) local ok, reason = guiScope(node, true) if not ok then return false, reason end state.guiInventoryRoot = node state.confirmedGuiRoot = node state.guiSelectionSource = "Klik pengguna" local cursor = node while cursor and cursor ~= playerGui do if cursor:IsA("ScreenGui") and (cursor.Name:lower() == "inventory" or cursor.Name:lower() == "backpack") then state.guiBagRoot = cursor; break end cursor = cursor.Parent end return true, "Daftar tas dipilih: " .. node:GetFullName() end state.findAutomaticInventoryGui = function() if state.guiInventoryRoot and state.guiInventoryRoot:IsDescendantOf(playerGui) then return end for _, path in ipairs(CONFIG.AutoInventoryGuiPaths) do local node = playerGui for _, name in ipairs(path) do node = node and node:FindFirstChild(name) end if node and node:IsA("ScrollingFrame") and guiScope(node, true, true) then state.guiInventoryRoot = node; state.confirmedGuiRoot = node state.guiBagRoot = playerGui:FindFirstChild(path[1]) state.guiSelectionSource = "Otomatis: path Inventory akun lokal yang terkonfirmasi dari log" return end end end state.PickInventoryAtPosition = function(x, y) local ok, hits = pcall(playerGui.GetGuiObjectsAtPosition, playerGui, x, y) if not ok then return false, "Pembacaan posisi GUI gagal: " .. tostring(hits) end for _, hit in ipairs(hits) do local scrolling, reason = Core.guiHitList(hit, playerGui, state.gui) if reason then return false, reason end if scrolling then local accepted, message = state.SelectInventoryGui(scrolling) if accepted then state.clickedGuiPath = hit:GetFullName(); state.pickError = nil end return accepted, message end end return false, "Belum menemukan daftar di bawah kartu yang diklik" end state.readGui = function() if not CONFIG.ReadInventoryGui then return nil end state.findAutomaticInventoryGui() local selected = state.guiInventoryRoot local allowed, reason, hiddenPath = guiScope(selected, selected ~= nil, selected ~= nil and state.confirmedGuiRoot == selected) if not allowed then state.guiDiagnostics = "Widget tas otomatis belum tersedia: " .. tostring(reason) .. ". Snapshot client/event masih dicari; widget yang belum dibuat tidak membuktikan tas kosong." return nil end local buckets, visited, uuidCards, imageCount, factCount, stopped, samples = {}, 0, 0, 0, 0, false, {} local currentList = selected state.guiIds = state.guiIds or setmetatable({}, { __mode = "k" }) local function identity(node) if not state.guiIds[node] then state.guiIdCounter = (state.guiIdCounter or 0) + 1; state.guiIds[node] = state.guiIdCounter end return state.guiIds[node] end local function inList(node) return Core.guiItemScope(node, currentList) end local function factsFor(card, uid, anonymous) local ok, attributes = pcall(card.GetAttributes, card) local facts = { name = uid or card.Name, attributes = ok and attributes or {}, labels = {}, images = {}, allowAnonymous = anonymous, path = card:GetFullName() } local stack, inspected = { card }, 0 while #stack > 0 and inspected < 100 do local child = table.remove(stack); inspected = inspected + 1; factCount = factCount + 1 if child:IsA("TextLabel") or child:IsA("TextButton") then table.insert(facts.labels, {name = child.Name, text = child.Text}) end if child:IsA("ImageLabel") or child:IsA("ImageButton") then table.insert(facts.images, {name = child.Name, image = child.Image}) end for _, nested in ipairs(child:GetChildren()) do table.insert(stack, nested) end end facts.truncated = #stack > 0 return facts end local function addCard(bucket, card, uid, anonymous) if bucket.checked[card] then return end bucket.checked[card] = true if factCount >= 100000 then stopped = true; return end local record = Core.guiRecord(factsFor(card, uid, anonymous), state.catalog) if record then local parents, cursor = {}, card.Parent while cursor and cursor ~= playerGui do table.insert(parents, identity(cursor)); cursor = cursor.Parent end record.GuiCardId = identity(card) -- Local widget identity, never an invented item UUID. table.insert(bucket.cards, { id = identity(card), ancestors = parents, record = record }) end end local roots, seenRoots = {selected}, {[selected] = true} if state.guiBagRoot and state.guiBagRoot:IsDescendantOf(playerGui) then local stack = {state.guiBagRoot} local checked = 0 while #stack > 0 and checked < CONFIG.MaxGuiInstances do local node = table.remove(stack); checked = checked + 1 if node:IsA("ScrollingFrame") and not seenRoots[node] then local allowedList = guiScope(node, true, true) local name = node.Name:lower() if allowedList and (name:find("inventory", 1, true) or name:find("list", 1, true) or name:find("fish", 1, true) or name:find("rod", 1, true) or name:find("bait", 1, true) or name:find("item", 1, true) or name:find("charm", 1, true) or name:find("pet", 1, true) or name:find("emote", 1, true) or name:find("abilit", 1, true)) then seenRoots[node] = true; table.insert(roots, node) end end for _, child in ipairs(node:GetChildren()) do table.insert(stack, child) end end end for _, root in ipairs(roots) do currentList = root if root ~= state.gui and root.Name ~= "RennInventoryScanner" then local bucket = { root = root, cards = {}, checked = {}, images = {}, quotas = {}, named = true, capacity = false } local stack = { root } while #stack > 0 do local node = table.remove(stack) visited = visited + 1 if visited > CONFIG.MaxGuiInstances then stopped = true; break end local lower = node.Name:lower() if lower:find("inventory", 1, true) or lower:find("backpack", 1, true) then bucket.named = true end if node:IsA("TextLabel") or node:IsA("TextButton") then local owned, maximum = node.Text:gsub("<[^>]*>", ""):match("([%d,]+)%s*/%s*([%d,]+)") if owned and maximum then bucket.capacity = true table.insert(bucket.quotas, node:GetFullName() .. "=" .. owned .. "/" .. maximum) end end local card = node:IsA("GuiObject") and node or nil local valueUUID if node:IsA("StringValue") and Core.uuid(node.Value) and node.Parent and node.Parent:IsA("GuiObject") then card = node.Parent; valueUUID = Core.uuid(node.Value) end if card then local ok, attributes = pcall(card.GetAttributes, card) attributes = ok and attributes or {} local uid = valueUUID or Core.uuid(card.Name) or Core.uuid(Core.first(attributes, { "UUID", "Uuid", "uuid", "Uid", "UniqueId", "ItemUUID" })) if uid then uuidCards = uuidCards + 1 if inList(card) then addCard(bucket, card, uid, false) end end end if (node:IsA("ImageLabel") or node:IsA("ImageButton")) and Core.icon(node.Image) ~= "" and inList(node) then imageCount = imageCount + 1; table.insert(bucket.images, node) end for _, child in ipairs(node:GetChildren()) do table.insert(stack, child) end if visited % 300 == 0 then task.wait(); if not state.alive then return nil end end end if bucket.named or bucket.capacity then for index, image in ipairs(bucket.images) do local cursor = image for _ = 1, 7 do if not cursor or cursor:IsA("ScrollingFrame") or not cursor:IsA("GuiObject") then break end if #samples < 5 and cursor == image.Parent then local facts = factsFor(cursor, nil, true) local text = {}; for _, label in ipairs(facts.labels) do table.insert(text, label.name .. "=" .. label.text:gsub("\n", "/"):sub(1, 100)) end table.insert(samples, "GUI sample: " .. facts.path .. " | " .. table.concat(text, "; "):sub(1, 450)) end addCard(bucket, cursor, nil, true); cursor = cursor.Parent if stopped then break end end if index % 20 == 0 then task.wait(); if not state.alive then return nil end end if stopped then break end end end table.insert(buckets, bucket) if stopped then break end end end local candidates, notes = {}, {} for _, bucket in ipairs(buckets) do local count = #Core.guiCards(bucket.cards) if bucket.named or bucket.capacity then for _, candidate in ipairs(bucket.cards) do table.insert(candidates, candidate) end table.insert(notes, bucket.root:GetFullName() .. ": " .. tostring(count) .. " kartu item") for index, quota in ipairs(bucket.quotas) do if index <= 8 then table.insert(notes, quota) end end end end for _, sample in ipairs(samples) do table.insert(notes, sample) end local records = Core.guiCards(candidates) -- Revalidate after traversal yields; changing the selected list must never mix two areas. if state.guiInventoryRoot ~= selected then return nil end local stillAllowed, finalReason, finalHiddenPath = guiScope(selected, true, state.confirmedGuiRoot == selected) if not stillAllowed then state.guiDiagnostics = "Area tas berubah saat dibaca: " .. tostring(finalReason) return nil end hiddenPath = finalHiddenPath or hiddenPath local noUUID = 0; for _, record in ipairs(records) do if not record.UUID then noUUID = noUUID + 1 end end if not state.catalogReady then state.guiDiagnostics = "Daftar tas terpilih; tunggu pemetaan katalog selesai sebelum mengunci item." return Core.bagInventory(state.bag), "Tas akun lokal: cache inventory (GUI + event; parsial)" end local changed = Core.bagMergeGui(state.bag, records, state.catalog, player.UserId, os.time()) local inventory = Core.bagInventory(state.bag) local flat = Core.normalize(inventory, state.catalog) state.guiReadMetadata = {capturedAt = state.bag.capturedAt > 0 and state.bag.capturedAt or os.time(), cached = not changed, retainedWidgets = hiddenPath ~= nil} state.guiDiagnostics = "Visibilitas tas: " .. (hiddenPath and ("tertutup/tersembunyi pada " .. hiddenPath) or "terbuka") .. "\nGUI diperiksa: " .. tostring(visited) .. "; kandidat kartu UUID: " .. tostring(uuidCards) .. "; gambar dalam daftar: " .. tostring(imageCount) .. "; field GUI: " .. tostring(factCount) .. "; record cocok katalog/attribute: " .. tostring(#records) .. (stopped and " [BATAS TERCAPAI]" or "") .. "\nUUID asli tidak tersedia pada " .. tostring(noUUID) .. " kartu.\n" .. table.concat(notes, "\n") local categories = {}; for _, row in ipairs(flat.rows) do categories[row.category] = true end local names = {}; for category in pairs(categories) do table.insert(names, category) end; table.sort(names) state.guiDiagnostics = state.guiDiagnostics .. "\nCache akun: " .. tostring(state.bag.userId) .. "; jenis: " .. table.concat(names, ", ") .. "\nDaftar tas diperiksa: " .. tostring(#roots) .. "; record tersimpan: " .. tostring(flat.total) .. "; remove tanpa identitas pasti: " .. tostring(state.bag.unresolvedRemovals) .. "\nSearch/hidden/tab tidak menghapus cache. Penghapusan membutuhkan UUID/event atau snapshot inventory." if #records == 0 then state.guiDiagnostics = state.guiDiagnostics .. "\nBelum ada kartu dikenali; ini tidak membuktikan tas kosong. Buka tas dan kosongkan pencarian." end -- An explicitly chosen list remains the source even at zero cards; Tools are not this bag. return inventory, "Tas akun lokal: cache inventory (GUI + event; parsial)" end local function getInventory(replion) if type(replion) ~= "table" or replion.Destroyed then return nil end local data = replion.Data local inventory, path = Core.inventory(data, CONFIG.InventoryPaths) if inventory then return inventory, path, data end if type(replion.Get) == "function" then for _, candidate in ipairs(CONFIG.InventoryPaths) do local ok, value = pcall(replion.Get, replion, candidate) if ok and type(value) == "table" then return value, table.concat(candidate, "."), data end end end if (replion._channel == "Inventory" or replion._channel == "Backpack") and type(data) == "table" then return data, "", data end return nil, nil, data end local function clearSourceConnections() for _, connection in ipairs(state.sourceConnections) do pcall(function() connection:Disconnect() end) end table.clear(state.sourceConnections) end local function bindSource(replion) if state.replion == replion then return end clearSourceConnections() state.replion = replion local function changed(_, path) if state.paused or not state.alive or state.queued then return end -- Replion also reports coins/stats. Only known inventory paths need a rescan. if replion._channel ~= "Inventory" and type(path) == "table" and type(path[1]) == "string" then local matches = false if Core.isEquipmentPath(path) then matches = true end for _, candidate in ipairs(CONFIG.InventoryPaths) do if path[1] == candidate[1] then matches = true; break end end if not matches then return end end state.queued = true spawnTask(function() task.wait(0.3); state.queued = false if state.alive and not state.paused then refresh() end end) end if type(replion.OnDataChange) == "function" then local ok, connection = pcall(replion.OnDataChange, replion, changed) if ok and connection then table.insert(state.sourceConnections, connection) end elseif type(replion.OnChange) == "function" then for _, candidate in ipairs(CONFIG.InventoryPaths) do local ok, connection = pcall(replion.OnChange, replion, candidate, function() changed() end) if ok and connection then table.insert(state.sourceConnections, connection) end end end if type(replion.BeforeDestroy) == "function" then local ok, connection = pcall(replion.BeforeDestroy, replion, function() clearSourceConnections(); state.replion = nil end) if ok and connection then table.insert(state.sourceConnections, connection) end end end local function readSource() state.activeReader = nil; state.observedWarning = nil; state.ownerNotes = {} state.abilityAdditions = 0 state.profileData = nil; state.profileId = nil; state.equipmentProfileData = nil if type(CONFIG.InventoryReader) == "function" then local value, name = CONFIG.InventoryReader() if type(value) ~= "table" then return nil, "InventoryReader belum mengembalikan tabel" end return value, name or "Custom reader", false end if state.replion and not state.replion.Destroyed and os.clock() < (state.nextSourceDiscovery or 0) then local inventory, path, data = getInventory(state.replion) if inventory and Core.profileScope(data, player.UserId, state.replion._channel or "Data", state.replion.ReplicateTo == "All", CONFIG.ReplionChannels) then state.profileData = data; state.profileId = state.replion._id or state.bag.channelId inventory, state.abilityAdditions = Core.withProfileAbilities(inventory, data, player.UserId, state.catalog) Core.bagReplace(state.bag, inventory, state.catalog, "Inventory") return inventory, "Replion " .. tostring(state.replion._channel or "Data") .. "." .. path, false end end state.nextSourceDiscovery = os.clock() + 30 state.liveCandidates = 0; state.clientProbeNotes = {} for _, entry in ipairs(state.clients) do local client = entry.client local channels = table.clone(CONFIG.ReplionChannels) -- Current Replion caches also accept replication IDs. Inventory events identify the relevant one. for id in pairs(state.observed.records) do table.insert(channels, id) end local readers = Core.cacheReaders({env.debug or false, debug or false}, { getupvalues = env.getupvalues or getupvalues, getupvalue = env.getupvalue or getupvalue, }) local names = {}; for _, reader in ipairs(readers) do table.insert(names, reader.name) end state.cacheCapability = #names > 0 and table.concat(names, ", ") or "Tidak tersedia" for _, reader in ipairs(readers) do local selfTest = state.cacheReaderProbes[reader.source] if not selfTest then selfTest = Core.probeCacheReader(reader); state.cacheReaderProbes[reader.source] = selfTest end table.insert(state.clientProbeNotes, "Uji API " .. reader.name .. ": " .. selfTest.status .. " | " .. selfTest.reason) end local candidates, probe = Core.clientCandidates(client, channels, readers) table.insert(state.clientProbeNotes, "Probe client: " .. entry.label .. " | lookup: " .. probe.lookups .. "; nil: " .. probe.emptyLookups .. "; error: " .. probe.lookupErrors) table.insert(state.clientProbeNotes, "Lookup error terakhir: " .. tostring(probe.lastLookupError or "Tidak ada")) table.insert(state.clientProbeNotes, "Pembacaan cache: " .. probe.cacheReads .. "; tabel terbaca: " .. probe.cacheTables .. "; replion ditemukan: " .. probe.cacheCandidates .. "; error: " .. probe.cacheErrors) for _, note in ipairs(probe.cacheNotes) do table.insert(state.clientProbeNotes, "Cache API " .. note) end local seen = {}; for _, candidate in ipairs(candidates) do seen[candidate.value] = true end for replion in pairs(entry.added or {}) do if not replion.Destroyed and not seen[replion] then table.insert(candidates, {value = replion, origin = "OnReplionAdded"}) end end state.liveCandidates = state.liveCandidates + #candidates for _, candidate in ipairs(candidates) do local replion = candidate.value if replion then local inventory, path, data = getInventory(replion) local label = replion._channel or candidate.origin state.dataShape = "Channel " .. tostring(label) .. ": " .. describeShape(data) local owns, proof = Core.profileScope(data, player.UserId, label, replion.ReplicateTo == "All", CONFIG.ReplionChannels) table.insert(state.ownerNotes, "Scope " .. tostring(label) .. ": " .. tostring(owns) .. " | " .. proof) if owns and not inventory and type(data) == "table" then state.equipmentProfileData = data end if inventory and owns then bindSource(replion) state.activeReader = entry.label state.profileData = data; state.profileId = replion._id or state.bag.channelId inventory, state.abilityAdditions = Core.withProfileAbilities(inventory, data, player.UserId, state.catalog) Core.bagReplace(state.bag, inventory, state.catalog, "Inventory") return inventory, "Replion " .. tostring(label) .. "." .. path, false end end end end local observed, record = Core.observedInventory(state.observed) if record then state.profileId = record.id end if observed and not record.complete and state.catalogReady and state.bag.seedSequence ~= record.sequence then if not state.bag.channelId or state.bag.channelId == record.id then state.bag.channelId = record.id local flat = Core.normalize(observed, state.catalog, {CollectRecords = true}) if not flat.truncated then for _, item in ipairs(flat.items) do Core.bagUpsert(state.bag, item, state.catalog, false) end state.bag.seedSequence = record.sequence end end end if state.readGui and (not record or not record.complete) then local guiInventory, label = state.readGui() if guiInventory then return guiInventory, label, true, state.guiReadMetadata end end if observed then clearSourceConnections(); state.replion = nil state.dataShape = "Protocol Inventory.Items | channel: " .. tostring(record.channel or "ID dari event") state.observedWarning = record.warning if record.complete then local equipProfile = state.equipmentObserved.profiles[record.id] if equipProfile then observed, state.abilityAdditions = Core.withProfileAbilities(observed, equipProfile.data, player.UserId, state.catalog) end return observed, "Replion events: Inventory (snapshot penuh)", false, {capturedAt = record.capturedAt or (state.bag.capturedAt > 0 and state.bag.capturedAt) or os.time(), cached = true} end return Core.bagInventory(state.bag), "Tas akun lokal: cache inventory (GUI + event; parsial)", true, {capturedAt = state.bag.capturedAt > 0 and state.bag.capturedAt or os.time(), cached = true} end -- Losing/recreating a GUI or a Replion instance is not inventory deletion evidence. if state.bag.revision > 0 then return Core.bagInventory(state.bag), "Tas akun lokal: cache inventory (GUI + event; parsial)", true, {capturedAt = state.bag.capturedAt, cached = true} end -- Fallback is explicitly partial and never added to Replion counts (avoids duplicate tools). local tools, seen = {}, {} for _, container in ipairs({ player:FindFirstChildOfClass("Backpack") or false, player.Character or false }) do if container then for _, tool in ipairs(container:GetChildren()) do if tool:IsA("Tool") and not seen[tool] then seen[tool] = true local attributes = tool:GetAttributes() table.insert(tools, { Name = tool.Name, Id = attributes.ItemId or attributes.Id, Type = attributes.ItemType or attributes.Type or "Tools", Icon = tool.TextureId, Quantity = attributes.Quantity or attributes.Amount or 1, Metadata = { Variant = attributes.Mutation, Weight = attributes.Weight }, }) end end end end return { Tools = tools }, "Backpack + Character (parsial)", true end local function readEquipment(inventory) local sources, equipmentCache = {}, {} if state.profileData then table.insert(sources, {data = state.profileData, name = "Replion profile"}) end if state.equipmentProfileData then table.insert(sources, {data = state.equipmentProfileData, name = "Replion equipment profile"}) end local eventProfile = state.profileId and state.equipmentObserved.profiles[state.profileId] if not eventProfile then local matched, count count = 0 for _, profile in pairs(state.equipmentObserved.profiles) do if profile.verified then matched = profile; count = count + 1 end end if count == 1 then eventProfile = matched end end if eventProfile then table.insert(sources, {data = eventProfile.data, name = "Replion equipment events"}) end local attributes = player:GetAttributes() table.insert(sources, {data = attributes, name = "LocalPlayer attributes"}) local held = {} if player.Character then for _, tool in ipairs(player.Character:GetChildren()) do if tool:IsA("Tool") then local record = tool:GetAttributes() record.Id = record.ItemId or record.Id record.UUID = record.UUID or record.ItemUUID or record.UniqueId record.Name = record.ItemName or tool.Name local entry = Core.lookup(state.catalog, record.Id, record.Name, "Fishing Rods") local actualType = record.ItemType or record.Type if entry or (actualType and Core.category(actualType) == "Fishing Rods") then table.insert(held, record) end end end end if #held == 1 then table.insert(sources, {data = {EquippedRod = held[1]}, name = "Local Character.Tool"}) end local equipment = Core.equipment({}, inventory, state.catalog, player.UserId, equipmentCache) local stats = Core.playerStats({}, player.UserId) for _, source in ipairs(sources) do local incoming = Core.equipment(source.data, inventory, state.catalog, player.UserId, equipmentCache) local incomingStats = Core.playerStats(source.data, player.UserId) for _, key in ipairs({"coins", "caught", "rarestFish"}) do if stats[key].status == "unknown" and incomingStats[key].status == "known" then stats[key] = incomingStats[key]; stats[key].source = source.name .. " / " .. incomingStats[key].source end end for _, key in ipairs({"rod", "bait", "ability", "pet", "potion"}) do if equipment[key].status == "unknown" and incoming[key].status ~= "unknown" then equipment[key] = incoming[key]; equipment[key].source = source.name .. " / " .. incoming[key].source elseif key == "rod" and incoming.rod.enchantKnown then local sameRod = equipment.rod.uuid and equipment.rod.uuid == incoming.rod.uuid or (not equipment.rod.uuid and equipment.rod.id and tostring(equipment.rod.id) == tostring(incoming.rod.id)) if sameRod then if not equipment.rod.enchantKnown then equipment.rod.enchants = incoming.rod.enchants; equipment.rod.enchantKnown = true end for _, index in ipairs({1, 2}) do local field = "enchant" .. index if not equipment.rod[field .. "Known"] and incoming.rod[field .. "Known"] then equipment.rod[field] = incoming.rod[field]; equipment.rod[field .. "Known"] = true end end end end end end if equipment.rod.status ~= "none" and (equipment.rod.enchant1Known or equipment.rod.enchant2Known) then local names = {} if equipment.rod.enchant1Known and equipment.rod.enchant1 ~= "Tidak ada" then table.insert(names, equipment.rod.enchant1) end if equipment.rod.enchant2Known and equipment.rod.enchant2 ~= "Tidak ada" then table.insert(names, equipment.rod.enchant2) end equipment.rod.enchants = #names > 0 and table.concat(names, ", ") or "Tidak ada" end state.equipment = equipment state.playerStats = stats local statLines = {} for _, key in ipairs({"coins", "caught", "rarestFish"}) do local slot = stats[key] table.insert(statLines, key .. ": " .. slot.display .. " | nilai: " .. tostring(slot.value) .. " | " .. slot.source) end local statFields = Core.playerStats(state.profileData or state.equipmentProfileData or (eventProfile and eventProfile.data), player.UserId).fields state.statsDiagnostics = "Statistik akun lokal:\n" .. table.concat(statLines, "\n") .. "\nField statistik profile: " .. #statFields .. (#statFields > 0 and ("\n" .. table.concat(statFields, "\n")) or " | Belum tersedia") state.equipmentDiagnostics = "Equip Rod: " .. equipment.rod.name .. " | " .. equipment.rod.source .. "\nEnchant rod aktif: " .. equipment.rod.enchants .. "\nEnchant 1: " .. equipment.rod.enchant1 .. "\nEnchant 2: " .. equipment.rod.enchant2 .. "\nEquip Bait: " .. equipment.bait.name .. " | " .. equipment.bait.source .. "\nEquip Ability: " .. equipment.ability.name .. " | " .. equipment.ability.source .. "\nEquip Pet: " .. equipment.pet.name .. " | " .. equipment.pet.source .. "\nEquip Potions: " .. equipment.potion.name .. " | " .. equipment.potion.source .. "\nEquipment events: " .. tostring(state.equipmentObserved.accepted) .. "\nScope equip event: " .. tostring(eventProfile and (eventProfile.scopeReason or "ID sesuai channel inventory yang teramati") or (state.profileData and "Equip diperiksa langsung dari profile snapshot aktif; ID event belum terhubung") or "Belum ada profile equip terhubung") local fieldLines = Core.equipmentFields(state.profileData or state.equipmentProfileData or (eventProfile and eventProfile.data), player.UserId) state.equipmentDiagnostics = state.equipmentDiagnostics .. "\nField equip profile: " .. tostring(#fieldLines) .. (#fieldLines > 0 and ("\n" .. table.concat(fieldLines, "\n")) or " | Belum tersedia") .. "\nField enchant rod aktif:\n" .. table.concat(equipment.rod.enchantFields or {}, "\n") end state.tradeMessage = "Kontrol trade tersedia melalui website." state.TradeChoices = function() local choices = Core.tradeChoices(state.currentInventory or {}, state.catalog) local rap for _, entry in ipairs(state.clients) do rap = Core.clientLookup(entry.client, "RAP") if rap then break end end if rap then for _, row in ipairs(choices) do row.trade.rap = Core.tradeRAP(rap.Data, row.category, row.id) or row.trade.rap end end return choices end local function tradeLocalInventory() local replion = state.replion local data = replion and not replion.Destroyed and replion.Data if type(data) ~= "table" or state.partial or not state.catalogReady then return nil, "Tunggu snapshot penuh dan katalog selesai" end if not Core.profileScope(data, player.UserId, replion._channel or "Data", replion.ReplicateTo == "All", CONFIG.ReplionChannels) then return nil, "Profile bukan akun lokal" end local inventory = Core.inventory(data, CONFIG.InventoryPaths) if not inventory then return nil, "Snapshot inventory tidak tersedia" end return Core.withProfileAbilities(inventory, data, player.UserId, state.catalog) end state.PrepareTrade = function(targetId, requests) local target = tonumber(targetId) and Players:GetPlayerByUserId(tonumber(targetId)) if not target or target == player then return false, "Pilih player target yang masih berada di server" end local inventory, err = tradeLocalInventory() if not inventory then return false, err end local plan, reason = Core.tradePlan(inventory, state.catalog, requests, {localUserId = player.UserId, targetUserId = target.UserId, fullSnapshot = true, catalogReady = true}) if not plan then return false, reason end plan.targetName = target.Name; state.tradePlan = plan; state.tradeTarget = target.UserId plan.batches = Core.tradeExecutionBatches(plan) state.tradeMessage = "Rencana " .. plan.total .. " unit, " .. #plan.batches .. " trade. Klik Mulai; target menyetujui tiap trade." status(state.tradeMessage); state.renderInfo() return true, plan end -- Only these calls are authorized by Mulai. Signatures come from the supplied game sources. local function tradeReplion(channel) for _, entry in ipairs(state.clients) do local value = Core.clientLookup(entry.client, channel) if value then return value end for replion in pairs(entry.added or {}) do if not replion.Destroyed and replion._channel == channel then return replion end end local readers = Core.cacheReaders({env.debug or false, debug or false}, { getupvalues = env.getupvalues or getupvalues, getupvalue = env.getupvalue or getupvalue, }) for _, candidate in ipairs(Core.clientCandidates(entry.client, {channel}, readers)) do local replion = candidate.value if not replion.Destroyed and replion._channel == channel then return replion end end end end local function tradeAdapter() if state.tradeAdapter then return state.tradeAdapter end local function load(path) local module = instancePath(path) if not module or not module:IsA("ModuleScript") then error("Modul trade belum tersedia: " .. table.concat(path, "."), 0) end local lastError for _, loader in ipairs(Core.requireLoaders(require, env.getrenv or getrenv)) do local ok, value = boundedRequire(module, loader.call, 5) if ok and type(value) == "table" then return value end lastError = value end error("Modul trade tidak dapat dibaca: " .. tostring(lastError), 0) end local data = load({"Shared", "Trading", "TradeData"}) local utility = load({"Shared", "ItemUtility"}) if type(data.FollowTradeRules) ~= "function" or data.MaxItemsInTrade ~= 20 or type(utility.GetItemDataFromItemType) ~= "function" or type(data.Remotes) ~= "table" or type(data.ConfirmCountdownTime) ~= "number" or data.ConfirmCountdownTime < 0 or data.ConfirmCountdownTime > 60 then error("Adapter berbeda dari source trade yang diberikan", 0) end for _, name in ipairs({"SendTradeOffer", "AddItem", "SetReady", "ConfirmTrade", "CancelTrade"}) do local remote = data.Remotes[name] if typeof(remote) ~= "Instance" or not remote:IsA("RemoteFunction") then error("RemoteFunction tidak tersedia: " .. name, 0) end end for _, name in ipairs({"TradeStarted", "TradeEnded", "TradeCompleted"}) do local remote = data.Remotes[name] if typeof(remote) ~= "Instance" or not remote:IsA("RemoteEvent") then error("RemoteEvent tidak tersedia: " .. name, 0) end end local adapter = {data = data, utility = utility, remotes = data.Remotes} state.tradeAdapter = adapter return adapter end state.ResolveTradeAdapter = tradeAdapter local function ownedTradeRecord(inventory, uuid) local found, collection, visited visited = 0 for kind, records in pairs(inventory) do if type(records) == "table" then for _, record in pairs(records) do visited = visited + 1 if visited > CONFIG.MaxInventoryNodes then error("Snapshot terlalu besar untuk memeriksa UUID", 0) end if type(record) == "table" and Core.uuid(record.UUID) == uuid then if found then error("UUID inventory berulang", 0) end found, collection = record, kind end end end end if not found then error("UUID pilihan sudah tidak dimiliki: " .. uuid, 0) end return found, collection end local function autoSummary(ctx) local replion = ctx.replion and not ctx.replion.Destroyed and ctx.replion or (ctx.channel and tradeReplion(ctx.channel)) if not replion or replion.Destroyed then return nil end local summary, err = Core.tradeSessionSummary(replion.Data, player.UserId, ctx.target.UserId, ctx.channel) if not summary then error(err, 0) end ctx.replion = replion; ctx.lastSummary = summary ctx.journal.lastSession = summary return summary end local function autoCheck(ctx) if not state.alive or state.autoTrade ~= ctx or ctx.stopped then error(ctx.reason or "Auto trade dihentikan", 0) end if ctx.failure then error(ctx.failure, 0) end if Players:GetPlayerByUserId(ctx.target.UserId) ~= ctx.target then error("Target meninggalkan server", 0) end if workspace:GetAttribute("TradingDisabled") ~= false then error("Trading game sedang dinonaktifkan/belum tersedia", 0) end end local function autoWait(ctx, seconds, message, predicate) ctx.deadline = os.clock() + seconds; ctx.message = message state.tradeMessage = "Trade " .. ctx.batchIndex .. "/" .. #ctx.batches .. ": " .. message status(state.tradeMessage) repeat autoCheck(ctx) local result = predicate() if result then return result end task.wait(0.1) until os.clock() >= ctx.deadline error(message .. ": batas waktu; pengiriman tidak diulang otomatis", 0) end local function autoRpc(ctx, name, ...) autoCheck(ctx) local args = table.pack(...) -- No raw call log; the pending lock remains part of transaction correctness. local done, result = false, nil ctx.rpcPending = true state.tradeOutstanding = (state.tradeOutstanding or 0) + 1 spawnTask(function() result = table.pack(pcall(function() return ctx.adapter.remotes[name]:InvokeServer(table.unpack(args, 1, args.n)) end)) done = true; ctx.rpcPending = false state.tradeOutstanding = math.max(0, (state.tradeOutstanding or 1) - 1) if ctx.stopped and name == "SendTradeOffer" then state.tradeRetryAfter = os.clock() + 12 end end) autoWait(ctx, 10, name, function() return done end) autoCheck(ctx) -- The controller ignores Ready/Confirm return values; their replicated flags must prove the effect. local ignoredReturn = (name == "SetReady" or name == "ConfirmTrade") and result[2] == nil and result[3] == nil if not result[1] or (result[2] ~= true and not ignoredReturn) then error(name .. " ditolak: " .. tostring(result[1] and result[3] or result[2]), 0) end end local function autoOffer(ctx, slots, quantities) local summary = autoSummary(ctx) if not summary then error("Sesi trade sudah tidak tersedia", 0) end local matches, reason = Core.tradeOfferMatches(summary, slots, quantities) if not matches then error(reason, 0) end return summary end local function anotherTradeSession(ctx) local function different(replion) if replion.Destroyed or replion._channel == ctx.channel then return false end local data = replion.Data if type(data) ~= "table" or type(data.PlayerList) ~= "table" then return false end for _, participant in pairs(data.PlayerList) do local ok, id = pcall(function() return participant.UserId end) if ok and id == player.UserId then return true end end return false end for _, entry in ipairs(state.clients) do for replion in pairs(entry.added or {}) do if different(replion) then return true end end for _, candidate in ipairs(Core.clientCandidates(entry.client, {}, {})) do if different(candidate.value) then return true end end end return false end state.StopAutoTrade = function(reason) local ctx = state.autoTrade state.RestorePanel() if not ctx then return false end ctx.stopped = true; ctx.reason = reason or "Dihentikan pengguna" state.autoTrade = nil if not ctx.success then state.tradeRetryAfter = os.clock() + 12 end ctx.journal.recording = false; ctx.journal.finishedAt = os.time(); ctx.journal.stopReason = ctx.reason for _, connection in ipairs(ctx.connections) do pcall(function() connection:Disconnect() end) end state.tradeMessage = ctx.reason .. " | selesai " .. ctx.finished .. "/" .. #ctx.batches .. " trade. Trade > Salin." status(state.tradeMessage); state.renderInfo(); if state.RenderTrade then state.RenderTrade() end -- Cancel only our still-matching session. An outstanding request may still return later. if not ctx.success and not ctx.rpcPending and ctx.adapter and ctx.channel then local ok, summary = pcall(autoSummary, ctx) if ok and summary then spawnTask(function() local replion = tradeReplion(ctx.channel) if state.autoTrade or not replion or replion ~= ctx.replion or replion.Destroyed then return end if anotherTradeSession(ctx) then return end local matched = Core.tradeSessionSummary(replion.Data, player.UserId, ctx.target.UserId, ctx.channel) if not matched then return end -- Cancel only the verified session; no recording. state.tradeOutstanding = (state.tradeOutstanding or 0) + 1 local called, result, err = pcall(function() return ctx.adapter.remotes.CancelTrade:InvokeServer() end) state.tradeOutstanding = math.max(0, (state.tradeOutstanding or 1) - 1) if not called or result == false then state.tradeError = tostring(err or result) end end) end end return true end state.StartAutoTrade = function(targetId, requests) if state.autoTrade then return state.StopAutoTrade("Dihentikan pengguna") end if state.tradeJournal and state.tradeJournal.recording then return false, "Hentikan rekaman sebelum Mulai auto trade" end if (state.tradeOutstanding or 0) > 0 or os.clock() < (state.tradeRetryAfter or 0) then return false, "Permintaan sebelumnya belum selesai/kedaluwarsa; periksa GUI game dan tunggu sebelum mulai ulang" end local ok, plan = state.PrepareTrade(targetId, requests) if not ok then return false, plan end local target = Players:GetPlayerByUserId(plan.targetUserId) local journal = Core.tradeJournal(player.UserId, target.UserId, plan) journal.mode = "autoTrade"; journal.startedAt = os.time(); journal.targetName = target.Name journal.protocol = "Source-verified calls; Replion participant/offer checks; server completion plus local unit deltas" journal.completedBatches = 0 local ctx = {target = target, plan = plan, batches = plan.batches, batchIndex = 1, finished = 0, journal = journal, connections = {}, usedChannels = {}, deadline = os.clock() + 20} state.autoTrade = ctx; state.tradeJournal = journal; state.lastTradeFrame = nil; state.nextTradeFrame = 0 state.HideForTradeRecording() spawnTask(function() while state.alive and state.autoTrade == ctx do local success, err = pcall(function() state.SetRecorderControls(ctx.deadline - os.clock()) -- No GUI scan or recording task. end) if not success then ctx.failure = "Pembacaan trade gagal: " .. tostring(err) end if os.clock() > ctx.deadline + 2 then state.StopAutoTrade("Batas waktu auto trade; periksa GUI game sebelum mulai ulang"); return end task.wait(0.5) end end) spawnTask(function() local succeeded, failure = pcall(function() ctx.adapter = tradeAdapter(); autoCheck(ctx) local remotes = ctx.adapter.remotes for _, name in ipairs({"TradeStarted", "TradeEnded", "TradeCompleted"}) do connect(remotes[name].OnClientEvent, function(...) if state.autoTrade ~= ctx or ctx.stopped then return end local args = table.pack(...) if name == "TradeStarted" then if ctx.phase ~= "invitation" or type(args[1]) ~= "string" or args[1] == "" or ctx.usedChannels[args[1]] then ctx.failure = "TradeStarted tidak cocok dengan undangan batch aktif" else ctx.channel = args[1]; ctx.usedChannels[ctx.channel] = true end elseif name == "TradeEnded" then if ctx.phase ~= "between" and not ctx.completed then ctx.failure = "Trade berakhir: " .. tostring(args[1]) end elseif name == "TradeCompleted" then local summaryOK, summary = pcall(autoSummary, ctx) summary = summaryOK and (summary or ctx.lastSummary) or nil if ctx.phase == "confirmation" and ctx.confirmRequested and summary and summary.localConfirmed == true and summary.targetConfirmed == true then ctx.completed = true end end end, ctx.connections) end for index, batch in ipairs(ctx.batches) do ctx.batchIndex = index; ctx.phase = "between"; ctx.channel = nil; ctx.replion = nil ctx.lastSummary = nil; ctx.completed = false; ctx.confirmRequested = false ctx.sessionMissingAt = nil autoCheck(ctx) -- A finished session may briefly retain IsTrading while the game cleans up. autoWait(ctx, 8, "Menunggu player tersedia", function() return not player:GetAttribute("IsTrading") and not target:GetAttribute("IsTrading") end) local public = tradeReplion("ReplicatedPlayerData") local settings = public and public.Data and public.Data["User_" .. target.UserId] if not settings or not settings.TradeSettings or not settings.TradeSettings.Trades or settings.TradeSettings.Trades == 0 then error("Target belum dapat menerima undangan trade", 0) end local inventory, invError = tradeLocalInventory() if not inventory then error(invError, 0) end local before = Core.tradeInventoryQuantities(inventory, state.catalog) local slots = {} for _, unit in ipairs(batch.slots) do local record, collection = ownedTradeRecord(inventory, unit.sourceUUID) local definition = ctx.adapter.utility.GetItemDataFromItemType(collection, record.Id) if not definition or not definition.Data or tostring(definition.Data.Id) ~= tostring(unit.id) or Core.category(definition.Data.Type) ~= unit.category or ctx.adapter.data.FollowTradeRules(definition, record) ~= true or record.Locked == true or record.TradeLocked == true or (type(record.Metadata) == "table" and (record.Metadata.Locked == true or record.Metadata.TradeLocked == true)) then error("Item berubah atau tidak mengikuti aturan trade: " .. unit.name, 0) end local flat = Core.normalize({[collection] = {record}}, state.catalog).rows[1] if not flat or flat.key ~= unit.key then error("Identitas/mutasi pilihan berubah", 0) end local slot = table.clone(unit); slot.itemType = definition.Data.Type; slot.sourceQuantity = before[unit.sourceUUID] slot.tradeStatus = "tradable"; slot.tradeSource = "TradeData.FollowTradeRules (runtime)" if not slot.sourceQuantity or slot.sourceQuantity < 1 then error("Stok pilihan tidak tersedia", 0) end table.insert(slots, slot) end local result = {index = index, status = "invitation", completion = "unverified"} ctx.phase = "invitation" autoRpc(ctx, "SendTradeOffer", target) autoWait(ctx, ctx.adapter.data.TradeOfferExpiration or 10, "Menunggu target menerima undangan", function() if ctx.channel then return autoSummary(ctx) end end) result.channel = ctx.channel; journal.targetAcceptance = "sessionParticipantsVerified" autoOffer(ctx, {}, false); ctx.phase = "offer"; result.status = "addingItems" local added = {} for _, slot in ipairs(slots) do autoOffer(ctx, added, false) autoRpc(ctx, "AddItem", slot.itemType, slot.sourceUUID) table.insert(added, slot) autoWait(ctx, 5, "Memeriksa item pada offer", function() local summary = autoSummary(ctx) if not summary then error("Sesi ditutup saat menambah item", 0) end if summary.localOffer.cards < #added then return false end local matches, reason = Core.tradeOfferMatches(summary, added, false) if not matches then error(reason, 0) end return true end) end autoWait(ctx, 65, "Menunggu kunci perubahan offer", function() local summary = autoOffer(ctx, slots, true) if type(summary.lastModifiedTime) ~= "number" then error("Waktu perubahan offer belum tersedia", 0) end return workspace:GetServerTimeNow() >= summary.lastModifiedTime + ctx.adapter.data.ConfirmCountdownTime end) result.status = "ready"; autoRpc(ctx, "SetReady", true) autoWait(ctx, 120, "Menunggu target Ready", function() local summary = autoOffer(ctx, slots, true) return summary.localReady == true and summary.targetReady == true and summary.playersReady == true end) local summary = autoOffer(ctx, slots, true) if type(summary.lastModifiedTime) ~= "number" or workspace:GetServerTimeNow() < summary.lastModifiedTime + ctx.adapter.data.ConfirmCountdownTime then error("Offer berubah sebelum konfirmasi", 0) end ctx.phase = "confirmation"; ctx.confirmRequested = true; result.status = "confirmation" autoRpc(ctx, "ConfirmTrade") autoWait(ctx, 120, "Menunggu target Confirm dan hasil server", function() if ctx.completed then return true end local active = autoSummary(ctx) if not active then ctx.sessionMissingAt = ctx.sessionMissingAt or os.clock() if not ctx.lastSummary or ctx.lastSummary.localConfirmed ~= true or ctx.lastSummary.targetConfirmed ~= true or os.clock() - ctx.sessionMissingAt > 8 then error("Sesi ditutup tanpa hasil server terverifikasi", 0) end return false end local matches, reason = Core.tradeOfferMatches(active, slots, true) if not matches then error(reason, 0) end if active.localReady ~= true or active.targetReady ~= true or active.playersReady ~= true then error("Ready berubah setelah konfirmasi", 0) end end) result.serverCompleted = true local after = autoWait(ctx, 8, "Memeriksa selisih inventory 1 unit per slot", function() local updated = tradeLocalInventory() if not updated then return false end local quantities = Core.tradeInventoryQuantities(updated, state.catalog) for _, slot in ipairs(slots) do if (before[slot.sourceUUID] or 0) - (quantities[slot.sourceUUID] or 0) ~= 1 then return false end end return quantities end) -- Exact unit deltas were already verified above; no historical delta log. result.status = "completed"; result.completion = "serverConfirmedWithLocalUnitDelta" ctx.finished = ctx.finished + 1; journal.completedBatches = ctx.finished; ctx.phase = "between" if index < #ctx.batches then autoWait(ctx, 5, "Menyiapkan undangan berikutnya", function() return os.clock() + 0.1 >= ctx.deadline end) end end ctx.success = true; journal.completion = "serverConfirmedWithLocalUnitDelta" end) if state.autoTrade == ctx then if not succeeded then journal.failure = tostring(failure) end state.StopAutoTrade(succeeded and ("Selesai mengirim " .. plan.total .. " unit ke " .. target.Name) or ("Auto trade berhenti: " .. tostring(failure))) end end) return true, plan end local function rowSignature(rows) local parts = {} for _, row in ipairs(rows) do table.insert(parts, row.key .. ":" .. tostring(row.qty) .. ":" .. row.icon .. ":" .. tostring(row.resolved) .. ":" .. tostring(row.minWeight) .. ":" .. tostring(row.maxWeight) .. ":" .. tostring(row.favoriteQty) .. ":" .. tostring(row.lockedQty) .. ":" .. table.concat(row.instances, ",")) end return table.concat(parts, "\n") end refresh = function(force) if not Core.beginRefresh(state, force) then return end spawnTask(function() local ok, err = pcall(function() local inventory, source, partial, readMetadata = readSource() if not inventory then status(source or "Inventori belum tersedia"); return end state.currentInventory = inventory readEquipment(inventory); state.renderEquipment() -- Full refresh runs on data events and a periodic fallback. local snapshot = Core.normalize(inventory, state.catalog, { MaxNodes = CONFIG.MaxInventoryNodes, Yield = function() task.wait(); if not state.alive then error("Tracker ditutup") end end, }) if not state.alive then return end partial = partial or snapshot.truncated snapshot.partial = partial snapshot.catalogReady = state.catalogReady snapshot.mappingComplete = state.catalogReady and snapshot.unresolved == 0 and snapshot.missingIcons == 0 local mutationData=snapshot.mutationDiagnostics snapshot.mutationMappingComplete = not partial and mutationData.unmapped==0 and mutationData.invalid==0 and mutationData.unknown==0 snapshot.cached = readMetadata and readMetadata.cached or false snapshot.retainedWidgets = readMetadata and readMetadata.retainedWidgets or false snapshot.unresolvedRemovals = partial and state.bag.unresolvedRemovals or 0 snapshot.equipment = {} snapshot.playerStats = {} for _, key in ipairs({"coins", "caught", "rarestFish"}) do snapshot.playerStats[key] = state.playerStats[key] end for key, slot in pairs(state.equipment) do snapshot.equipment[key] = {name = slot.name, id = slot.id, uuid = slot.uuid, status = slot.status, source = slot.source, enchants = slot.enchants, enchantKnown = slot.enchantKnown, enchant1 = slot.enchant1, enchant2 = slot.enchant2, enchant1Known = slot.enchant1Known, enchant2Known = slot.enchant2Known} if slot.items then snapshot.equipment[key].items = {} for _, item in ipairs(slot.items) do table.insert(snapshot.equipment[key].items, {name = item.name, id = item.id, uuid = item.uuid, status = item.status}) end end end local itemCollection = Core.path(inventory, CONFIG.ItemPath) snapshot.itemRecords = 0 if type(itemCollection) == "table" then for _, item in pairs(itemCollection) do if type(item) == "table" then snapshot.itemRecords = snapshot.itemRecords + 1 end end end snapshot.categories = Core.categoryTotals(snapshot.rows) local categoryLines = {} for _, entry in ipairs(snapshot.categories) do table.insert(categoryLines, entry.category .. ": " .. entry.quantity .. " unit; " .. entry.records .. " record; " .. entry.groups .. " kelompok; belum dipetakan: " .. entry.unresolved) end local signature = source .. "\n" .. rowSignature(snapshot.rows) local changed = signature ~= state.signature state.signature = signature; state.baselineSource = source; state.lastTruncated = snapshot.truncated state.baselineCatalog = state.catalog.count if changed or state.partial ~= partial then state.revision = state.revision + 1 end state.rows = snapshot.rows; state.snapshot = snapshot; state.source = source state.capturedAt = readMetadata and readMetadata.capturedAt or os.time(); state.partial = partial snapshot.capturedAt = state.capturedAt state.diagnostics = table.concat({ "Info diperbarui: " .. os.date("%H:%M:%S"), "Player: " .. player.Name .. " | PlaceId: " .. tostring(game.PlaceId), "LocalPlayer.UserId: " .. tostring(player.UserId), "Area GUI dipilih: " .. tostring(state.guiInventoryRoot and state.guiInventoryRoot:GetFullName() or "Path tas otomatis belum termuat"), "Widget tas diklik: " .. tostring(state.clickedGuiPath or "Belum ada"), "Pemilihan GUI: " .. tostring(state.pickError or "Tidak ada error"), "Mode pemilihan tas: " .. tostring(state.guiSelectionSource or "Otomatis; menunggu data client/widget"), state.equipmentDiagnostics or "Equip belum diperiksa", "Trade: " .. tostring(state.tradeMessage or "Pilih target dan item melalui tombol Trade"), state.statsDiagnostics or "Statistik belum diperiksa", table.concat(state.ownerNotes or {}, "\n"), "Source: " .. source, "Waktu ekstraksi data: " .. os.date("%H:%M:%S", state.capturedAt) .. " | cache terakhir: " .. tostring(snapshot.cached), "Tanpa membuka tas: pembacaan otomatis aktif | snapshot inventory penuh: " .. tostring(not partial), "Cache execute sebelumnya: " .. tostring(state.reusedCache == true) .. " (hanya PlayerGui dan UserId sesi yang sama)", "Replion module: " .. (state.modulePath or state.moduleError or "Belum ditemukan"), "Client readers: " .. tostring(#state.clients) .. "; replion snapshot candidates: " .. tostring(state.liveCandidates or 0), "Introspeksi cache client: " .. tostring(state.cacheCapability or "Belum diperiksa"), table.concat(state.clientProbeNotes or {}, "\n"), "Profile shape: " .. describeShape(state.profileData), "Ability dari Profile.Abilities.Inventory: " .. tostring(state.abilityAdditions) .. " record", "Equipment error: " .. tostring(state.equipmentError or "Tidak ada"), "Reader aktif: " .. tostring(state.activeReader or "Belum menemukan snapshot penuh"), table.concat(state.discoveryNotes, "\n"), state.guiDiagnostics or "GUI cadangan belum diperlukan/diperiksa", state.dataShape or "Channel data belum tersedia", "Inventory shape: " .. describeShape(inventory), "Inventory.Items: " .. describeShape(itemCollection) .. " | record teramati: " .. tostring(snapshot.itemRecords), "Replion remote listeners: " .. tostring(state.remoteCount) .. " | " .. (state.remotePath or "Belum tersedia"), "Inventory events: " .. tostring(state.observed.accepted) .. "; non-inventory diabaikan: " .. tostring(state.observed.ignored), "Protocol terakhir: " .. tostring(state.observed.lastPath or "Belum ada update Inventory"), "Item event terakhir: " .. describeShape(state.observed.lastItem), "Payload ditolak: " .. tostring(state.observed.rejected) .. " | " .. tostring(state.observed.lastError or "Tidak ada"), state.observedWarning or (partial and "Snapshot lama tidak dapat dipulihkan hanya dari update ikan baru." or "Snapshot Inventory penuh terbaca pada client."), "Rows: " .. tostring(#state.rows) .. "; total: " .. tostring(snapshot.total), "Total adalah jumlah kuantitas seluruh kategori yang terbaca.", "Rincian kategori:\n" .. table.concat(categoryLines, "\n"), "Belum dipetakan: " .. tostring(snapshot.unresolved) .. "; tanpa ikon: " .. tostring(snapshot.missingIcons), "Contoh ID/definisi belum lengkap:\n" .. Core.unmappedCatalogRows(snapshot.rows), Core.mutationDiagnostics(snapshot), "Pemetaan item lengkap: " .. tostring(snapshot.mappingComplete) .. " | kelengkapan data dan katalog diperiksa terpisah", "Pemetaan mutasi lengkap: " .. tostring(snapshot.mutationMappingComplete) .. " | field yang hilang tetap belum terbaca", "Skipped: " .. tostring(snapshot.skipped) .. "; truncated: " .. tostring(snapshot.truncated), "Parsial: " .. tostring(partial) .. " | Polling: " .. tostring(CONFIG.RefreshSeconds) .. " detik", "Scope: inventory akun lokal yang tersedia pada client, bukan seluruh player server.", }, "\n") if changed or force then refreshCategories(); render() end local note = partial and " | PARSIAL: bukan total tas" or "" if snapshot.truncated then note = note .. " | DATA TERPOTONG" end if snapshot.unresolved > 0 then note = note .. " | " .. tostring(snapshot.unresolved) .. " belum dipetakan" end if not state.catalogReady then note = note .. " | Memuat katalog" end status(os.date("%H:%M:%S") .. " | " .. source .. note) end) if not ok and state.alive then status("Baca gagal; snapshot dipertahankan. Klik Info.") -- Keep one current error, without accumulating diagnostic history. state.lastError = tostring(err) end state.renderLoading(); state.renderInfo() if Core.endRefresh(state) then refresh(true) end end) end local function addLabels(value, target) if type(value) ~= "table" then return end for key, descriptor in pairs(value) do if type(descriptor) == "table" then local data = type(descriptor.Data) == "table" and descriptor.Data or descriptor local name = data.Name or data.DisplayName if type(name) == "string" then target[tostring(data.Id or key)] = name target[tostring(key)] = name end elseif type(descriptor) == "string" then target[tostring(key)] = descriptor end end end local function updateCatalogView(force) local now = os.clock() if not force and now < (state.nextCatalogView or 0) then return end state.nextCatalogView = now + 0.5 state.renderLoading(); state.renderInfo() end local function catalogRequire(module, labels) state.catalogModule = module:GetFullName() updateCatalogView() local ok, value = boundedRequire(module) state.catalogModules = state.catalogModules + 1 if not ok or type(value) ~= "table" then if labels then state.catalogLabelFailures = state.catalogLabelFailures + 1 else state.catalogFailures = state.catalogFailures + 1 end state.catalogLastFailure = state.catalogModule .. " | " .. tostring(ok and ("Hasil require: " .. type(value)) or value) return false, value end return true, value end local function scanCatalog() local seen = {} state.catalogRoots = {} state.catalogPhase = "Item" for _, path in ipairs(CONFIG.CatalogPaths) do local root = instancePath(path) if root and not seen[root] then local beforeModules, beforeEntries = state.catalogModules, state.catalog.count local stack = { {item = root, category = Core.catalogCategory(path[#path])} } local count = 0 while #stack > 0 and state.alive do local pending = table.remove(stack) local item, fallbackCategory = pending.item, pending.category if not seen[item] then seen[item] = true for _, child in ipairs(item:GetChildren()) do local childCategory = not child:IsA("ModuleScript") and Core.catalogCategory(child.Name) or nil table.insert(stack, {item = child, category = childCategory or fallbackCategory}) end if item:IsA("ModuleScript") then local ok, value = catalogRequire(item) if ok and type(value) == "table" then local report = Core.ingestCatalog(state.catalog, value, item.Name, fallbackCategory, { Collection = path[#path] == "Items" and "Items" or nil, Yield = function() task.wait(); if not state.alive then error("Tracker ditutup") end end, }) if report.truncated then state.catalogLastFailure = item:GetFullName() .. " | Batas pembacaan tabel katalog tercapai" end end end count = count + 1 if count % 4 == 0 then task.wait() end end end table.insert(state.catalogRoots, root:GetFullName() .. " | " .. tostring(state.catalogModules - beforeModules) .. " modul; " .. tostring(state.catalog.count - beforeEntries) .. " definisi baru") end end for _, config in ipairs({ { name = "Rarity", paths = CONFIG.TierPaths, target = state.catalog.tiers }, { name = "Mutasi", paths = CONFIG.VariantPaths, target = state.catalog.variants }, { name = "Enchant", paths = CONFIG.EnchantPaths, target = state.catalog.enchants} }) do state.catalogPhase = config.name for _, path in ipairs(config.paths) do local root = instancePath(path) if root then if root:IsA("ModuleScript") then local ok, value = catalogRequire(root, true) if ok then addLabels(value, config.target) end else for _, module in ipairs(root:GetChildren()) do if module:IsA("ModuleScript") then local ok, value = catalogRequire(module, true) if ok and type(value) == "table" then addLabels({ [module.Name] = value }, config.target) end end end end end end end local mutationLabels = table.clone(state.catalog.variants) for id, name in pairs(mutationLabels) do Core.addMutation(state.catalog, name, id) end state.mutationCount = 0; for _ in pairs(state.catalog.mutationNames) do state.mutationCount = state.mutationCount + 1 end state.catalogReady = true end local function buildCatalog() if not state.alive or state.catalogBuilding then return false end state.catalogBuilding = true state.catalogReady = false; state.catalogStarted = os.clock(); state.catalogFinished = nil state.catalogModules = 0; state.catalogFailures = 0; state.catalogLabelFailures = 0 state.catalogError = nil; state.catalogLastFailure = nil local ok, err = pcall(scanCatalog) state.catalogBuilding = false if not state.alive then return false end state.catalogFinished = os.clock(); state.catalogModule = nil state.catalogPhase = ok and "Selesai" or "Gagal" if not ok then state.catalogReady = false; state.catalogError = tostring(err) state.lastError = tostring(err) end updateCatalogView(true) refresh(true) end local function findReplionModule() local packages = ReplicatedStorage:FindFirstChild("Packages") if not packages then return nil end local index = packages:FindFirstChild("_Index") local preferred = index and index:FindFirstChild(CONFIG.ReplionPackage) local preferredModule = preferred and (preferred:FindFirstChild("replion") or preferred:FindFirstChild("Replion")) if preferredModule and preferredModule:IsA("ModuleScript") then return preferredModule end local direct = packages:FindFirstChild("Replion") or packages:FindFirstChild("replion") if direct and direct:IsA("ModuleScript") then return direct end if index then for _, package in ipairs(index:GetChildren()) do if package.Name:lower():find("replion", 1, true) then local module = package:FindFirstChild("replion") or package:FindFirstChild("Replion") if module and module:IsA("ModuleScript") then return module end end end end return nil end local function startInventoryObserver() if not CONFIG.ObserveInventoryEvents then return end local hooked = setmetatable({}, { __mode = "k" }) local supported = { Added = true, Removed = true, Set = true, Update = true, ArrayUpdate = true } local function hook(instance) if hooked[instance] or not supported[instance.Name] or not instance:IsA("RemoteEvent") then return end local folder = instance.Parent if not folder or folder.Name ~= "Remotes" or not folder.Parent or folder.Parent.Name:lower() ~= "replion" then return end local module = findReplionModule() if module and module.Parent.Name ~= "Packages" and folder.Parent ~= module then return end if state.remoteFolder and folder ~= state.remoteFolder then return end state.remoteFolder = folder; state.remotePath = folder:GetFullName() hooked[instance] = true; state.remoteCount = state.remoteCount + 1 connect(instance.OnClientEvent, function(...) if not state.alive then return end local args = table.pack(...) local options = { MaxNodes = CONFIG.MaxInventoryNodes, LocalUserId = player.UserId, PersonalChannels = CONFIG.ReplionChannels } local equipOk, equipError = pcall(Core.observeEquipment, state.equipmentObserved, instance.Name, args, options) if not equipOk then state.equipmentError = tostring(equipError) end local previousEquipEvents = state.lastEquipmentEvents or 0 local equipmentChanged = state.equipmentObserved.accepted ~= previousEquipEvents state.lastEquipmentEvents = state.equipmentObserved.accepted local ok, changed if state.catalogReady then ok, changed = pcall(Core.observeBag, state.observed, state.bag, instance.Name, args, options, state.catalog) else ok, changed = pcall(Core.observe, state.observed, instance.Name, args, options) end if not ok then state.observed.rejected = state.observed.rejected + 1; state.observed.lastError = tostring(changed) elseif (changed or equipmentChanged) and not state.paused and not state.queued then state.queued = true spawnTask(function() task.wait(0.15); state.queued = false if state.alive and not state.paused then refresh() end end) end end) end connect(ReplicatedStorage.DescendantAdded, hook) for _, instance in ipairs(ReplicatedStorage:GetDescendants()) do hook(instance) end end discoverClient = function() if state.discovering then return false end state.discovering = true; state.discoveryNotes = {} status("Membaca snapshot tas dari module game...") local modules, seenModules = {}, {} local function add(module) if module and module:IsA("ModuleScript") and not seenModules[module] then seenModules[module] = true; table.insert(modules, module) end end local packages = ReplicatedStorage:FindFirstChild("Packages") if packages then add(packages:FindFirstChild("Replion") or packages:FindFirstChild("replion")) end local canonical = findReplionModule(); add(canonical) if canonical then add(canonical:FindFirstChild("Client") or canonical:FindFirstChild("client")) end if #modules == 0 then state.moduleError = "Packages.Replion belum tersedia"; state.discovering = false; return false end local seenClients = {}; for _, entry in ipairs(state.clients) do seenClients[entry.client] = true end local loaders = Core.requireLoaders(require, env.getrenv or getrenv) for _, module in ipairs(modules) do for _, loader in ipairs(loaders) do local ok, value = boundedRequire(module, loader.call, CONFIG.ReplionRequireTimeout) if not state.alive then state.discovering = false; return false end local client = ok and Core.resolveClient(value) local label = module:GetFullName() .. " / " .. loader.name if client then if not seenClients[client] then seenClients[client] = true local entry = {client = client, label = label, added = {}} table.insert(state.clients, entry) if type(client.OnReplionAdded) == "function" then local function added(replion) if type(replion) == "table" and type(replion.Data) == "table" then local count = 0; for _ in pairs(entry.added) do count = count + 1 end if count < 64 then entry.added[replion] = true end if state.alive and not state.paused then refresh() end end end local connected, connection = pcall(client.OnReplionAdded, client, added) if not connected then connected, connection = pcall(client.OnReplionAdded, added) end if connected and connection then table.insert(state.connections, connection) end end end state.client = client; state.modulePath = module:GetFullName(); state.moduleError = nil table.insert(state.discoveryNotes, "OK client: " .. label) else table.insert(state.discoveryNotes, "Gagal client: " .. label .. " | " .. tostring(ok and "Tidak ada API Client" or value)) end end end state.discovering = false if #state.clients == 0 then state.moduleError = "Tidak ada Client Replion yang dapat dibaca; lihat percobaan module di Info." end return #state.clients > 0 end -- In-memory controls and exports. No GUI, clipboard, local log file, or hook. state.GetSnapshot = function() return state.snapshot end state.ExportJson = function() if not state.snapshot then return nil, "Belum ada snapshot" end return HttpService:JSONEncode({ schema = "renn-inventory/v1", userId = player.UserId, username = player.Name, source = state.source, capturedAt = state.capturedAt, inventory = state.snapshot }) end state.Refresh = function() refresh(true) end -- Website policy; player lookup runs only while Auto Trade is enabled. do local policy = {enabled = false, status = "Auto Trade nonaktif", completed = {}, cursor = 1} state.automation = policy state.SetAutomation = function(value) if type(value) ~= "table" or policy.version == value.version then return end if state.autoTrade then state.StopAutoTrade("Konfigurasi Auto Trade berubah dari website") end policy.version, policy.enabled = value.version, value.enabled == true policy.targets = type(value.targets) == "table" and value.targets or {} policy.items = type(value.items) == "table" and value.items or {} policy.completed, policy.cursor, policy.running = {}, 1, nil policy.status = policy.enabled and "Menunggu username target masuk server" or "Auto Trade nonaktif" end spawnTask(function() while state.alive do if policy.enabled then if policy.running and state.autoTrade ~= policy.running then local finished = policy.running policy.running = nil if finished.success then policy.completed[policy.cursor] = true; policy.cursor = policy.cursor + 1 policy.status = "Pengiriman target selesai" if policy.cursor > #policy.targets then policy.enabled = false; policy.status = "Seluruh target selesai; Auto Trade nonaktif" policy.finishedVersion = policy.version end else policy.enabled = false; policy.status = state.tradeMessage or "Trade dihentikan; periksa hasil sebelum memulai kembali" policy.finishedVersion = policy.version end end if policy.enabled and not state.autoTrade and not policy.running then local wanted = policy.targets[policy.cursor] if type(wanted) ~= "string" then policy.enabled = false; policy.status = "Username target tidak valid"; policy.finishedVersion = policy.version else local target -- Never resolve arbitrary IDs, DisplayName, or a foreign server. for _, candidate in ipairs(Players:GetPlayers()) do if candidate ~= player and candidate.Name:lower() == wanted:lower() then target = candidate; break end end if target then local accepted, reason = state.StartAutoTrade(target.UserId, policy.items) if accepted then policy.running = state.autoTrade; policy.status = "Mengirim ke " .. target.Name else policy.enabled = false; policy.status = tostring(reason); policy.finishedVersion = policy.version end else policy.status = "Menunggu " .. wanted .. " masuk server" end end end end task.wait(10) end end) end -- Included by tools/build-rennstats.mjs; not a second collector entrypoint. do local config = type(env.RENNSTATS_CONFIG) == "table" and env.RENNSTATS_CONFIG or {} local url = tostring(config.Url or ""):gsub("/+$", "") local key = tostring(config.Key or "") env.RENNSTATS_CONFIG = nil local requestFn = env.request or env.http_request or request or http_request or (syn and syn.request) or (http and http.request) or (fluxus and fluxus.request) local session = HttpService:GenerateGUID(false) local upload, registered, lastVersion, lastFull, failures = nil, false, nil, 0, 0 local seenCommands, receipts, acknowledged = {}, {}, {} state.transport = {status = "Menunggu konfigurasi", sentBytes = 0, failures = 0} state.DataReady = function() local snapshot = state.snapshot return state.catalogReady and state.catalog.count > 0 and snapshot ~= nil and snapshot.partial == false and snapshot.truncated ~= true and snapshot.mappingComplete == true end local function post(payload, timeout) if not state.alive then error("Collector ditutup", 0) end state.transport.httpStatus = nil payload.userId, payload.session = player.UserId, session local body = HttpService:JSONEncode(payload) local response = requestFn({Url = url .. "/api.php", Method = "POST", Timeout = timeout or 15, Headers = {["Content-Type"] = "application/json", ["Authorization"] = "Bearer " .. key}, Body = body}) if type(response) ~= "table" then error("Respons HTTP kosong", 0) end local code = tonumber(response.StatusCode or response.Status or response.status_code) state.transport.httpStatus = code if code ~= 200 then local decoded, failure = pcall(HttpService.JSONDecode, HttpService, response.Body or response.body or "") local detail = decoded and type(failure) == "table" and type(failure.error) == "string" and failure.error or nil if detail then detail = detail:gsub(key, "[key]"):gsub("[%c]", " "):sub(1, 240) end error("HTTP " .. tostring(code or "unknown") .. (detail and (": " .. detail) or ""), 0) end local result = HttpService:JSONDecode(response.Body or response.body or "") if type(result) ~= "table" or result.ok ~= true then error("Web menolak laporan: " .. tostring(result and result.error), 0) end state.transport.sentBytes = state.transport.sentBytes + #body return result end local function command(command) if type(command) ~= "table" or type(command.id) ~= "string" or seenCommands[command.id] then return end seenCommands[command.id] = true -- Mark before running: mutations are never repeated after a lost response. local receipt = {id = command.id, ok = false, message = "Perintah tidak dikenal"} local ok, err = pcall(function() local args = type(command.args) == "table" and command.args or {} if command.action == "refresh" then state.Refresh(); receipt.ok = true; receipt.message = "Pembacaan dijadwalkan" elseif command.action == "pause" then state.paused = args.paused == true; receipt.ok = true; receipt.message = state.paused and "Pembacaan dijeda" or "Pembacaan aktif"; if not state.paused then state.Refresh() end elseif command.action == "diagnostics" then receipt.ok = true; receipt.message = state.GetDiagnostics() elseif command.action == "trade_stop" then state.automation.enabled = false; state.automation.status = "Dihentikan dari website" state.StopAutoTrade("Dihentikan dari website"); receipt.ok = true; receipt.message = state.tradeMessage elseif command.action == "trade_plan" or command.action == "trade_start" then if state.autoTrade then receipt.message = "Trade masih aktif; hentikan terlebih dahulu"; return end local target, selection = tonumber(args.targetUserId), args.items if type(selection) ~= "table" then receipt.message = "Pilihan item kosong"; return end local accepted, result if command.action == "trade_plan" and type(args.username) == "string" then result = Core.tradePlan(state.currentInventory or {}, state.catalog, selection, {localUserId = player.UserId, targetUserId = player.UserId + 1, fullSnapshot = not state.partial, catalogReady = state.catalogReady}) if result then result.targetName = args.username; result.batches = Core.tradeExecutionBatches(result) accepted = true; state.tradeMessage = "Stok valid untuk " .. args.username .. "; identitas penerima diverifikasi saat masuk server" else accepted = false; result = "Pilihan/stok belum dapat divalidasi" end elseif command.action == "trade_plan" then accepted, result = state.PrepareTrade(target, selection) else accepted, result = state.StartAutoTrade(target, selection) end receipt.ok = accepted == true receipt.message = receipt.ok and state.tradeMessage or tostring(result) if receipt.ok then receipt.plan = {total = result.total, batches = #result.batches, targetName = result.targetName} end elseif command.action == "inventory_gui" then local node = playerGui if type(args.path) ~= "table" or #args.path > 20 then receipt.message = "Path tas tidak valid"; return end for _, name in ipairs(args.path) do node = node and node:FindFirstChild(tostring(name)) end receipt.ok, receipt.message = state.SelectInventoryGui(node) if receipt.ok then state.Refresh() end elseif command.action == "inventory_pick" then receipt.ok, receipt.message = state.PickInventoryAtPosition(tonumber(args.x), tonumber(args.y)) if receipt.ok then state.Refresh() end end end) if not ok then receipt.ok = false; receipt.message = tostring(err) end receipts[command.id] = receipt end local function consume(result) for _, id in ipairs(result.acknowledged or {}) do receipts[id] = nil; acknowledged[id] = true end for _, entry in ipairs(result.commands or {}) do command(entry) end if result.settings then CONFIG.RefreshSeconds = math.clamp(tonumber(result.settings.readSeconds) or 15, 3, 120) end if result.automation then state.SetAutomation(result.automation) end end local function metadata(snapshot) local ctx = state.autoTrade return {username = player.Name, displayName = player.DisplayName, placeId = game.PlaceId, gameId = game.GameId, jobId = game.JobId, version = "rennstats/1.0", source = state.source, status = state.status, paused = state.paused, lastError = state.lastError, catalogReady = state.catalogReady, playerStats = snapshot and snapshot.playerStats or state.playerStats, equipment = snapshot and snapshot.equipment or state.equipment, trade = {active = ctx ~= nil, message = state.tradeMessage, targetUserId = ctx and ctx.target.UserId, totalBatches = ctx and #ctx.batches, batch = ctx and ctx.batchIndex, finished = ctx and ctx.finished, phase = ctx and ctx.phase, result = state.tradeJournal and state.tradeJournal.completion}, diagnostics = state.GetDiagnostics()} end state.HandleWebCommand = command state.WebReceipts = receipts local closeReader = state.Close state.Close = function() if not state.alive then return end state.automation.enabled = false state.StopAutoTrade("Collector ditutup") if registered then pcall(post, {action = "goodbye"}, 5); registered = false end closeReader() end spawnTask(function() if not url:match("^https://[^/]+") or key == "" or type(requestFn) ~= "function" then state.transport.status = "Atur URL HTTPS dan Key dari website; executor memerlukan request/http_request" return end while state.alive do local ok, err = pcall(function() if not registered then if not state.DataReady() then state.transport.status = "Menunggu snapshot penuh dan seluruh nama/ikon terpetakan"; return end consume(post({action = "hello", metadata = metadata(state.snapshot)})); registered = true end local pending = {} for _, value in pairs(receipts) do table.insert(pending, value) end local ctx, policy = state.autoTrade, state.automation local result = post({action = "control", receipts = pending, automationVersion = policy.finishedVersion, control = {paused = state.paused, enabled = policy.enabled, status = policy.status, target = policy.targets and policy.targets[policy.cursor], version = policy.version, active = ctx ~= nil, message = state.tradeMessage, batch = ctx and ctx.batchIndex, finished = ctx and ctx.finished}}) consume(result) if state.DataReady() and (lastVersion == nil or os.clock() - lastFull >= 600) and not state.paused then if not upload then if state.busy then return end local snapshot, revision = state.snapshot, state.revision local choices = state.TradeChoices() -- Module discovery may yield; a report must come from one completed read. if state.busy or state.snapshot ~= snapshot or state.revision ~= revision or not state.DataReady() then return end local raw = HttpService:JSONEncode({inventory = snapshot, choices = choices, metadata = metadata(snapshot), capturedAt = snapshot.capturedAt or state.capturedAt, schema = "rennstats/v1"}) local ranges = Core.uploadRanges(raw, 196608) upload = {id = HttpService:GenerateGUID(false), raw = raw, version = state.revision, part = 1, total = #ranges, ranges = ranges} end while state.alive and upload.part <= upload.total do local part = upload.part consume(post({action = "upload", uploadId = upload.id, part = part, total = upload.total, chunk = upload.raw:sub(upload.ranges[part][1], upload.ranges[part][2])})) upload.part = part + 1 task.wait() -- Let inventory/trade events run between chunks. end if state.alive then consume(post({action = "commit", uploadId = upload.id, total = upload.total})) lastVersion, lastFull = upload.version, os.clock(); upload = nil end end failures = 0; state.transport.status = "Terhubung"; state.transport.lastSentAt = os.time() -- Commands already acknowledged by the server are safe to forget locally. for id in pairs(acknowledged) do seenCommands[id] = nil; acknowledged[id] = nil end end) if not state.alive then return end if not ok then failures = math.min(failures + 1, 6); state.transport.failures = state.transport.failures + 1 state.transport.status = tostring(err) state.lastError = tostring(err) if state.transport.httpStatus == 409 then state.Close(); return end if state.transport.httpStatus == 410 then registered, upload, lastVersion = false, nil, nil state.automation.enabled = false state.StopAutoTrade("Sesi web kedaluwarsa; Auto Trade dihentikan") end if state.transport.httpStatus == 401 or state.transport.httpStatus == 403 then state.Close(); return end end task.wait(math.min(60, 10 * 2 ^ failures)) end end) end spawnTask(function() startInventoryObserver() discoverClient() refresh(true) buildCatalog() end) spawnTask(function() local nextDiscover, nextCatalog = os.clock() + 10, os.clock() + 30 while state.alive do task.wait(CONFIG.RefreshSeconds) if not state.alive then return end if not state.client and os.clock() >= nextDiscover then discoverClient(); nextDiscover = os.clock() + 10 end if os.clock() >= nextCatalog and (state.catalog.count == 0 or state.catalogError or (state.snapshot and (state.snapshot.unresolved > 0 or state.snapshot.missingIcons > 0))) then buildCatalog(); nextCatalog = os.clock() + 30 end if not state.paused then refresh() end end end) return state