--[[ Simplest client for exchanging setups with comments and likes. ]] local json = require 'lib/json' local endpoint = 'http://se.api.acstuff.ru' -- local endpoint = 'http://127.0.0.1:12016' local temporaryName = ac.getFolder(ac.FolderID.AppDataLocal)..'/Temp/ac-se-shared.ini' local temporaryBackupName = ac.getFolder(ac.FolderID.AppDataLocal)..'/Temp/ac-se-backup.ini' ---@type string local userKey ac.uniqueMachineKeyAsync(function (err, data) if err then ac.error('Failed to get key: '..err) end userKey = ac.checksumSHA256('LB83XurHhTPhpmTc'..data) end) local function rest(method, url, data, callback, errorHandler) if not userKey then setTimeout(function () rest(method, url, data, callback, errorHandler) end, 4) return end if method == 'GET' and data then local f = true for k, v in pairs(data) do url = url..(f and '?' or '&')..k..'='..v f = false end end if not callback then callback = function (response) ac.log('Successfully executed: '..url..', response: '..stringify(response)) end end if not errorHandler then errorHandler = function (err) ac.warn(err) end end web.request(method, endpoint..'/'..url, { ['Content-Type'] = 'application/json', ['X-User-Key'] = userKey }, method ~= 'GET' and json.encode(data) or nil, function (err, response) if err then return errorHandler(err) end if response.status >= 400 then local parsed = try(function () return json.decode(response.body) end, function () end) if parsed and parsed.error then err = parsed.error else err = response.body end return errorHandler(err) end try(function() callback(json.decode(response.body)) end, errorHandler) end) end local limit = 20 local function refreshGenList(url, params, callback, offset, resultList) if not offset then offset, resultList = 0, {} end rest('GET', url, table.chain(params, {offset = offset}), function (response) for _, v in ipairs(response) do table.insert(resultList, v) end if #response >= limit then refreshGenList(url, params, callback, offset + #response, resultList) else callback(resultList) end end, function (err) ac.warn('Failed to get list of '..url..': '..err) callback(#resultList > 0 and resultList or err) end) end local setupsOrder = { {'Hot', '(statDislikes * 10 - statLikes * 20 - statDownloads - statComments * 2) / max(60, @now - createdDate)'}, {'Popular', '-statDownloads'}, {'Liked', 'statDislikes - statLikes * 2'}, {'Newest', '-createdDate'}, } local stored = ac.storage{ introduced = false, userName = '', setupsFilterTrack = true, setupsOrder = 1 } if #stored.userName == 0 then stored.userName = ac.getDriverName(0) or 'User' end local ownUserID local listOfSetups, listOfComments local listOfSetupsPrev, listOfCommentsPrev local likedSetups, dislikedSetups = {}, {} local likedComments, dislikedComments = {}, {} local downloadedSetups = {} local initializing = false local currentlyApplying = false local currentlySubmittingComment = false local itemSize = vec2(100, 130) local commentSize = vec2(100, 130) local discussingItem local discussingComments = {} local setupTooltips = {} local downloadedAsFiles = {} local ownColor = rgbm(1, 1, 0, 1) local function getSetupData(setupInfo, incrementDownloads, callback) local cached = downloadedSetups[setupInfo.setupID] if cached and (cached.data or cached.err) then if incrementDownloads and not cached.incremented then cached.incremented = true setupInfo.statDownloads = setupInfo.statDownloads + 1 rest('POST', 'setup-download-counts/'..setupInfo.setupID) end callback(cached.err, cached.data) elseif cached then table.insert(cached, callback) else downloadedSetups[setupInfo.setupID] = {callback} rest('GET', 'setups/'..setupInfo.setupID, nil, function (response) if incrementDownloads then setupInfo.statDownloads = setupInfo.statDownloads + 1 rest('POST', 'setup-download-counts/'..setupInfo.setupID) end local list = downloadedSetups[setupInfo.setupID] downloadedSetups[setupInfo.setupID] = {data = response.data, incremented = incrementDownloads} for _, v in ipairs(list) do v(nil, response.data) end end, function (err) local list = downloadedSetups[setupInfo.setupID] downloadedSetups[setupInfo.setupID] = {err = err, incremented = true} for _, v in ipairs(list) do v(err, nil) end end) end end local function downloadSetupAsFile(setupInfo) if currentlyApplying or downloadedAsFiles[setupInfo.setupID] then return end currentlyApplying = true getSetupData(setupInfo, true, function (err, data) currentlyApplying = false local name = 'generic/loaded-'..setupInfo.name..'.ini' local filename = ac.getFolder(ac.FolderID.UserSetups)..'/'..ac.getCarID(0)..'/'..name io.save(filename, data) ac.refreshSetups() ui.toast(ui.Icons.Confirm, 'Setup loaded as “'..name..'”', function () io.deleteFile(filename) ac.refreshSetups() downloadedAsFiles[setupInfo.setupID] = false end) downloadedAsFiles[setupInfo.setupID] = true end) end local knownNames = { ['FUEL'] = function (v) return string.format('Fuel set to %s L', v) end, ['BRAKE_POWER_MULT'] = function (v) return string.format('Brake power set to %s%%', v) end, ['ENGINE_LIMITER'] = function (v) return string.format('Engine limiter set to %s%%', v) end, ['FRONT_BIAS'] = function (v) return string.format('Brake bias set to %s%%', v) end, ['FINAL_RATIO'] = 'Final gear ratio', ['GEARSET'] = 'Gear set', ['ARB_FRONT'] = 'ARB (front)', ['ARB_REAR'] = 'ARB (rear)', } local function getItemDisplayName(item, value, hint) local n = knownNames[item] if n then return type(n) == 'function' and n(value) or n end if item:find('PRESSURE_[LR]F') then return 'Tyre pressure (front)' end if item:find('PRESSURE_[LR]R') then return 'Tyre pressure (rear)' end if item:find('ROD_LENGTH_[LR]F') then return 'Suspension height (front)' end if item:find('ROD_LENGTH_[LR]R') then return 'Suspension height (rear)' end if item:find('SPRING_RATE_[LR]F') then return 'Suspension wheel rate (front)' end if item:find('SPRING_RATE_[LR]R') then return 'Suspension wheel rate (rear)' end if item:find('TOE_OUT_[LR]F') then return 'Toe (front)' end if item:find('TOE_OUT_[LR]R') then return 'Toe (rear)' end if item:find('CAMBER_[LR]F') then return 'Camber (front)' end if item:find('CAMBER_[LR]R') then return 'Camber (rear)' end if hint then return hint end local id = item:find('WING_(%d)') if id then return 'Wing #'..id end return item end local function getSetupTooltip(setupInfo) local known = setupTooltips[setupInfo.setupID] if known == nil then setupTooltips[setupInfo.setupID] = false getSetupData(setupInfo, false, function (err, data) if err then setupTooltips[setupInfo.setupID] = 'Failed to get setup data: '..err else local parsed = table.map(ac.INIConfig.parse(data).sections, function (item, index) return item.VALUE and tonumber(item.VALUE[1]), index end) local spinners = table.map(ac.getSetupSpinners(), function (i) return i.defaultValue and {i.defaultValue, i.label}, i.name end) local custom = {} for k, v in pairs(parsed) do if spinners[k] and spinners[k][1] ~= v then table.insert(custom, getItemDisplayName(k, v, spinners[k][2])) end end custom = table.distinct(table.filter(custom, function (item) return #item > 0 end)) table.sort(custom) if #custom == 0 then setupTooltips[setupInfo.setupID] = 'No changes from default setup are detected.' else setupTooltips[setupInfo.setupID] = 'Changes:\n• '..table.join(custom, ';\n• ')..'.' end local tyresName = ac.getTyresLongName(0, parsed['TYRES'] or 999) if #tyresName > 0 then setupTooltips[setupInfo.setupID] = setupTooltips[setupInfo.setupID]..'\n\nTyres: '..ac.getTyresLongName(0, parsed['TYRES']) end end end) end return setupTooltips[setupInfo.setupID] end local function refreshSetups() if not listOfSetups then local key = math.random() listOfSetups = key refreshGenList('setups', {carID = ac.getCarID(0), trackID = stored.setupsFilterTrack and ac.getTrackID() or nil, orderBy = setupsOrder[stored.setupsOrder][2]}, function (ret) if listOfSetups == key then listOfSetups = ret listOfSetupsPrev = ret end end) end return (type(listOfSetups) == 'table' or type(listOfSetups) == 'string') and listOfSetups or listOfSetupsPrev end local function refreshComments() if not listOfComments then local key = math.random() listOfComments = key refreshGenList('comments', {setupID = discussingItem.setupID}, function (ret) if listOfComments == key then listOfComments = ret listOfCommentsPrev = ret end end) table.clear(likedComments) table.clear(dislikedComments) rest('GET', 'comment-likes', {setupID = discussingItem.setupID}, function (data) for _, v in ipairs(data) do table.insert(v.direction == 1 and likedComments or dislikedComments, v.commentID) end end) end return (type(listOfComments) == 'table' or type(listOfComments) == 'string') and listOfComments or listOfCommentsPrev end local function initialLoading() if initializing then return end initializing = true rest('GET', 'user', {carID = ac.getCarID(0), carName = ac.getCarName(0), trackID = ac.getTrackID(), trackName = ac.getTrackName()}, function (response) ownUserID = response.userID or error('UserID is missing') ac.log('My user ID: '..ownUserID) end, function (err) ac.warn('Failed to get own user ID: '..err) end) rest('GET', 'likes', {carID = ac.getCarID(0)}, function (data) for _, v in ipairs(data) do table.insert(v.direction == 1 and likedSetups or dislikedSetups, v.setupID) end end) end local function removeSetup(id) rest('DELETE', 'setups/'..id, nil, function () ui.toast(ui.Icons.Warning, 'Shared setup removed') listOfSetups = nil end, function (err) ui.toast(ui.Icons.Warning, 'Failed to remove shared setup: '..err) end) end local function removeComment(id) rest('DELETE', 'comments/'..id, nil, function () ui.toast(ui.Icons.Warning, 'Comment removed') listOfComments = nil end, function (err) ui.toast(ui.Icons.Warning, 'Failed to remove comment: '..err) end) end local icons = ui.atlasIcons('res/icons.png', 4, 1, { Like = {1, 1}, Dislike = {1, 2}, Comments = {1, 3}, Download = {1, 4}, }) local iconSize = vec2(10, 10) local iconAlign = vec2(0, 0.6) local iconLikeAlign = vec2(0, 0) local iconDislikeAlign = vec2(0, 1) local function shareSetup(name) ac.saveCurrentSetup(temporaryName) rest('POST', 'setups', { carID = ac.getCarID(0), trackID = ac.getTrackID(), name = name, userName = stored.userName, data = io.load(temporaryName) }, function (response) ui.toast(ui.Icons.Settings, 'Setup shared', function () removeSetup(response.setupID) end) listOfSetups = nil end, function (err) ui.toast(ui.Icons.Warning, 'Failed to share setup: '..err) end) end local function likeButtons(path, item, likedList, dislikedList, itemID, contextTable) local liked = table.contains(likedList, itemID) local disliked = table.contains(dislikedList, itemID) if ui.button(string.format(' %d##likes', item.statLikes)) then if liked then item.statLikes = item.statLikes - 1 table.removeItem(likedList, itemID) rest('PATCH', path..'/'..itemID, contextTable) else item.statLikes = item.statLikes + 1 table.insert(likedList, itemID) rest('PATCH', path..'/'..itemID, table.chain(contextTable, {direction = 1})) if disliked then item.statDislikes = item.statDislikes - 1 table.removeItem(dislikedList, itemID) end end end ui.addIcon(icons.Like, iconSize, iconLikeAlign, liked and rgbm.colors.lime or rgbm.colors.white) if ui.itemHovered() then ui.setTooltip(string.format('Likes: %d', item.statLikes)) end ui.sameLine(0, 4) if ui.button(string.format(' %d##dislikes', item.statDislikes)) then if disliked then item.statDislikes = item.statDislikes - 1 table.removeItem(dislikedList, itemID) rest('PATCH', path..'/'..itemID, contextTable) else item.statDislikes = item.statDislikes + 1 table.insert(dislikedList, itemID) rest('PATCH', path..'/'..itemID, table.chain(contextTable, {direction = -1})) if liked then item.statLikes = item.statLikes - 1 table.removeItem(likedList, itemID) end end end ui.addIcon(icons.Dislike, iconSize, iconDislikeAlign, disliked and rgbm.colors.red or rgbm.colors.white) if ui.itemHovered() then ui.setTooltip(string.format('Dislikes: %d', item.statDislikes)) end end function script.windowMainSettings() if ui.checkbox('Show window in setup', ac.isWindowOpen('main_setup')) then ac.setWindowOpen('main_setup', not ac.isWindowOpen('main_setup')) end end local function windowGeneric() if discussingItem then if ui.button(' Back') then discussingItem = nil listOfComments = nil listOfCommentsPrev = nil return end ui.addIcon(ui.Icons.ArrowLeft, iconSize, iconAlign, rgbm.colors.white) local item = discussingItem local comment = discussingComments[item.setupID] or '' ui.sameLine() ui.text('Comments ('..item.name..' by '..item.userName..')') local comments = refreshComments() if not comments then ui.text('Loading list of comments…') return end if type(comments) == 'string' then ui.text('Failed to load comments:') ui.text(comments) return end ui.childWindow('commentsScroll', ui.availableSpace():sub(vec2(0, 40)), function () ui.offsetCursorY(8) for _, v in ipairs(comments) do if ui.areaVisible(commentSize) then local y = ui.getCursorY() ui.pushID(v.commentID) ui.pushFont(ui.Font.Small) if v.userID == ownUserID then ui.pushStyleColor(ui.StyleColor.Text, ownColor) end ui.text(v.userName) if v.userID == ownUserID then ui.popStyleColor() end ui.sameLine(0, 0) ui.text(string.format(' • %s', os.date('%Y/%m/%d %H:%M', v.createdDate))) ui.popFont() ui.text(v.data) ui.pushFont(ui.Font.Small) if ui.button(' Reply') then comment = '@'..v.userName..' '..comment end ui.addIcon(ui.Icons.Undo, iconSize, iconAlign) ui.sameLine(0, 4) likeButtons('comment-likes', v, likedComments, dislikedComments, v.commentID, {setupID = item.setupID}) if v.userID == ownUserID then ui.sameLine(0, 4) if ui.button(' Delete') then removeComment(v.commentID) item.statComments = item.statComments - 1 end ui.addIcon(ui.Icons.Delete, iconSize, iconAlign) end ui.popFont() ui.popID() ui.offsetCursorY(12) itemSize.y = ui.getCursorY() - y else ui.offsetCursorY(commentSize.y) end end end) local _, submitted comment, _, submitted = ui.inputText('Add a comment…', comment, ui.InputTextFlags.Placeholder) ui.sameLine(0, 4) local canSend = #comment:trim() > 0 and not currentlySubmittingComment if (ui.button('Send', vec2(ui.availableSpaceX(), 0), canSend and 0 or ui.ButtonFlags.Disabled) or submitted) and canSend then currentlySubmittingComment = true rest('POST', 'comments', { setupID = item.setupID, userName = stored.userName, data = comment:trim() }, function (response) currentlySubmittingComment = false ui.toast(ui.Icons.Settings, 'Comment posted', function () removeComment(response.commentID) item.statComments = item.statComments - 1 end) listOfComments = nil discussingComments[item.setupID] = '' item.statComments = item.statComments + 1 end, function (err) currentlySubmittingComment = false ui.toast(ui.Icons.Warning, 'Failed to post a comment: '..err) end) end discussingComments[item.setupID] = comment return end local setups = refreshSetups() if not setups then ui.text('Loading list of setups…') initialLoading() return end if type(setups) == 'string' then ui.text('Failed to load setups:') ui.text(setups) return end ui.alignTextToFramePadding() ui.header('Found setups:') ui.pushFont(ui.Font.Small) ui.sameLine(0, 0) ui.offsetCursorX(ui.availableSpaceX() - 144) ui.setNextItemWidth(120) ui.combo('##sort', setupsOrder[stored.setupsOrder][1], function () if ui.checkbox('Show setups for current track only', stored.setupsFilterTrack) then stored.setupsFilterTrack = not stored.setupsFilterTrack listOfSetups = nil end ui.offsetCursorY(12) ui.header('Sort:') ui.offsetCursorY(4) for i, v in ipairs(setupsOrder) do if ui.selectable(v[1], i == stored.setupsOrder) then stored.setupsOrder = i listOfSetups = nil end end end) ui.sameLine(0, 4) ui.button('…') ui.itemPopup(ui.MouseButton.Left, function () ui.text('Your name: '..stored.userName) if ui.selectable('Change name', false) then ui.modalPrompt('Change name', 'New name:', stored.userName, function (newName) if not newName or #newName:trim() == 0 then return end rest('POST', 'user', { userName = newName:trim() }, function () ui.toast(ui.Icons.Confirm, 'Name changed') stored.userName = newName:trim() listOfSetups = nil end, function (err) ui.toast(ui.Icons.Warning, 'Couldn’t change name: '..err) end) end) end if ui.itemHovered() then ui.setTooltip('Changing name would also change it for all published content') end end) ui.popFont() ui.childWindow('scroll', ui.availableSpace():sub(vec2(0, 40)), function () ui.offsetCursorY(8) if #setups == 0 then ui.text('') return end for _, v in ipairs(setups) do if ui.areaVisible(itemSize) then local y = ui.getCursorY() ui.pushID(v.setupID) ui.beginGroup() ui.pushFont(ui.Font.Title) ui.text(v.name:trim()) ui.popFont() ui.pushFont(ui.Font.Small) ui.sameLine(0, 0) ui.offsetCursorY(10) ui.text(' by ') ui.sameLine(0, 0) if v.userID == ownUserID then ui.pushStyleColor(ui.StyleColor.Text, ownColor) end ui.text(v.userName) if v.userID == ownUserID then ui.popStyleColor() end ui.offsetCursorY(-10) if not stored.setupsFilterTrack then ui.text('Track:') ui.sameLine(80) ui.text(v.trackID) end -- ui.text('Author:') -- ui.sameLine(80) -- if v.userID == ownUserID then ui.pushStyleColor(ui.StyleColor.Text, ownColor) end -- ui.text(v.userName) -- if v.userID == ownUserID then ui.popStyleColor() end ui.text('Posted:') ui.sameLine(80) ui.text(os.date('%Y/%m/%d %H:%M', v.createdDate)) ui.text('Downloads:') ui.sameLine(80) ui.text(v.statDownloads) ui.endGroup() if ui.itemHovered() then local tooltip = getSetupTooltip(v) if tooltip then ui.setTooltip(tooltip) end end if ac.isSetupAvailableToEdit() then if ui.button(' Apply', not currentlyApplying and 0 or ui.ButtonFlags.Disabled) then currentlyApplying = true getSetupData(v, true, function (err, data) currentlyApplying = false if err then ui.toast(ui.Icons.Warning, 'Failed to load setup: '..err) else ac.saveCurrentSetup(temporaryBackupName) ui.toast(ui.Icons.Confirm, 'Setup loaded', function () ui.toast(ui.Icons.Confirm, 'Previous setup restored') ac.loadSetup(temporaryBackupName) end) io.save(temporaryName, data) ac.loadSetup(temporaryName) end end) end ui.addIcon(icons.Download, iconSize, iconAlign) if ui.itemHovered() then ui.setTooltip('Setup will be instantly applied (with an option to revert back). To download setup, use context menu.') end ui.itemPopup(function () if ui.selectable('Download', downloadedAsFiles[v.setupID]) then downloadSetupAsFile(v) end end) else if ui.button('Download', downloadedAsFiles[v.setupID] and ui.ButtonFlags.Active or not currentlyApplying and 0 or ui.ButtonFlags.Disabled) then downloadSetupAsFile(v) end if ui.itemHovered() then ui.setTooltip('Unable to apply setup directly outside of setup menu, but it can be loaded. Use settings to enable Setup Exchange window in setup menu.') end end ui.sameLine(0, 4) likeButtons('likes', v, likedSetups, dislikedSetups, v.setupID, {carID = ac.getCarID(0)}) ui.sameLine(0, 4) if ui.button(string.format(' %d##comments', v.statComments)) then discussingItem = v end ui.addIcon(icons.Comments, iconSize, iconAlign) if ui.itemHovered() then ui.setTooltip(string.format('Comments: %d', v.statComments)) end if v.userID == ownUserID then ui.sameLine(0, 4) if ui.button(' Delete') then removeSetup(v.setupID) end ui.addIcon(ui.Icons.Delete, iconSize, iconAlign) end ui.popFont() ui.offsetCursorY(12) ui.popID() itemSize.y = ui.getCursorY() - y else ui.offsetCursorY(itemSize.y) end end end) ui.offsetCursorY(12) if ui.button('Share current setup', vec2(ui.availableSpaceX(), 0)) then ui.modalPrompt('Share setup', 'Name the setup:', nil, 'Share', 'Cancel', nil, nil, function (name) if name then shareSetup(name) end end) end if ui.itemHovered() then ui.setTooltip('Your name: '..ac.getDriverName(0)) end end local introHeight = 100 function script.windowMain() if not stored.introduced then ui.offsetCursorY(ui.availableSpaceY() / 2 - introHeight / 2) local y = ui.getCursorY() ui.offsetCursorX(ui.availableSpaceX() / 2 - 32) ui.image('icon.png', 64) ui.offsetCursorY(20) ui.textWrapped('Setup Exchange is a service for sharing, downloading and discussing car setups. All shared setups are kept in a public database.\n\n' ..'Optionally, there is also a setup menu integration, allowing to apply shared setups directly with a single click. Would you want to enable it? You can always toggle it in app settings.\n\n') if ui.button('Yes, enable setup integration', vec2(ui.availableSpaceX(), 0)) then stored.introduced = true ac.setWindowOpen('main_setup', true) end if ui.button('OK, keep setup integration disabled', vec2(ui.availableSpaceX(), 0)) then stored.introduced = true end introHeight = ui.getCursorY() - y else windowGeneric() end end function script.windowSetup() ui.pushFont(ui.Font.Title) ui.textAligned('Setup Exchange', vec2(0.5, 0.5), vec2(ui.availableSpaceX(), 34)) ui.popFont() windowGeneric() end