From 158aa138be202b3f1d8d2d07326fb50dbd9a7136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B5=D1=81=D1=82=D0=B5=D1=80=D0=BE=D0=B2=20=D0=A0?= =?UTF-8?q?=D1=83=D1=81=D0=BB=D0=B0=D0=BD?= Date: Mon, 9 Feb 2026 16:30:47 +0300 Subject: [PATCH] Add reputation module for Faceit --- manifest.json | 1 + src/faceit/reputation/reputation.js | 71 +++++++++++++ src/faceit/resources/templates.js | 152 ++++++++++++++++++++++++++++ src/faceit/room/matchroom.js | 127 ++++++++++++++++++++++- src/faceit/service/profiles.js | 14 +-- 5 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 src/faceit/reputation/reputation.js diff --git a/manifest.json b/manifest.json index e320a2c..a6a3036 100644 --- a/manifest.json +++ b/manifest.json @@ -49,6 +49,7 @@ "src/faceit/service/service.js", "src/faceit/service/profiles.js", "src/faceit/service/integrations.js", + "src/faceit/reputation/reputation.js", "src/module/moduleManager.js" ] } diff --git a/src/faceit/reputation/reputation.js b/src/faceit/reputation/reputation.js new file mode 100644 index 0000000..24225bf --- /dev/null +++ b/src/faceit/reputation/reputation.js @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 TerraMiner. All Rights Reserved. + */ + +const REPUTATION_TOXIC = 'toxic'; +const REPUTATION_FRIENDLY = 'friendly'; +const REPUTATION_NEUTRAL = 'neutral'; +const REPUTATION_STORAGE_KEY = 'player_reputation'; + +async function getPlayerReputation(playerId) { + try { + const result = await CLIENT_STORAGE.get([REPUTATION_STORAGE_KEY]); + const data = result[REPUTATION_STORAGE_KEY] || {}; + const entry = data[playerId]; + if (!entry) return null; + return { rating: entry.rating, updatedAt: entry.updatedAt, meetCount: entry.meetCount || 0 }; + } catch (e) { + error('getPlayerReputation failed', e); + return null; + } +} + +async function setPlayerReputation(playerId, rating) { + try { + const result = await CLIENT_STORAGE.get([REPUTATION_STORAGE_KEY]); + const data = result[REPUTATION_STORAGE_KEY] || {}; + const existing = data[playerId] || {}; + data[playerId] = { rating, updatedAt: Date.now(), meetCount: existing.meetCount || 0 }; + await CLIENT_STORAGE.set({ [REPUTATION_STORAGE_KEY]: data }); + } catch (e) { + error('setPlayerReputation failed', e); + } +} + +async function incrementMeetCount(playerId) { + try { + const result = await CLIENT_STORAGE.get([REPUTATION_STORAGE_KEY]); + const data = result[REPUTATION_STORAGE_KEY] || {}; + const entry = data[playerId] || { rating: REPUTATION_NEUTRAL, updatedAt: 0, meetCount: 0 }; + entry.meetCount = (entry.meetCount || 0) + 1; + if (!data[playerId]) data[playerId] = { rating: REPUTATION_NEUTRAL, updatedAt: 0, meetCount: 0 }; + data[playerId] = { ...data[playerId], meetCount: entry.meetCount }; + await CLIENT_STORAGE.set({ [REPUTATION_STORAGE_KEY]: data }); + } catch (e) { + error('incrementMeetCount failed', e); + } +} + +async function resetPlayerReputation(playerId) { + try { + const result = await CLIENT_STORAGE.get([REPUTATION_STORAGE_KEY]); + const data = result[REPUTATION_STORAGE_KEY] || {}; + const existing = data[playerId]; + if (!existing) return; + const meetCount = existing.meetCount || 0; + data[playerId] = { rating: REPUTATION_NEUTRAL, updatedAt: Date.now(), meetCount }; + await CLIENT_STORAGE.set({ [REPUTATION_STORAGE_KEY]: data }); + } catch (e) { + error('resetPlayerReputation failed', e); + } +} + +async function getAllReputations() { + try { + const result = await CLIENT_STORAGE.get([REPUTATION_STORAGE_KEY]); + return result[REPUTATION_STORAGE_KEY] || {}; + } catch (e) { + error('getAllReputations failed', e); + return {}; + } +} diff --git a/src/faceit/resources/templates.js b/src/faceit/resources/templates.js index 70d0516..4eece02 100644 --- a/src/faceit/resources/templates.js +++ b/src/faceit/resources/templates.js @@ -12,6 +12,8 @@ let MATCH_COUNTER_ARROW_TEMPLATE; let MATCH_HISTORY_POPUP_TEMPLATE; let PLAYER_WINRATE_TABLE_TEMPLATE; let TEAM_WINRATE_TABLE_TEMPLATE; +let PLAYER_REPUTATION_BADGE_TEMPLATE; +let PLAYER_REPUTATION_INLINE_TEMPLATE; let SKILL_LEVELS_INFO_TABLE_TEMPLATE; let FORECAST_STYLES_TEMPLATE; @@ -28,6 +30,8 @@ function initTemplates() { MATCH_HISTORY_POPUP_TEMPLATE = htmlToElement(MATCH_HISTORY_POPUP_HTML); PLAYER_WINRATE_TABLE_TEMPLATE = htmlToElement(PLAYER_WINRATE_TABLE_HTML); TEAM_WINRATE_TABLE_TEMPLATE = htmlToElement(TEAM_WINRATE_TABLE_HTML); + PLAYER_REPUTATION_BADGE_TEMPLATE = htmlToElement(PLAYER_REPUTATION_BADGE_HTML); + PLAYER_REPUTATION_INLINE_TEMPLATE = htmlToElement(PLAYER_REPUTATION_INLINE_HTML); SKILL_LEVELS_INFO_TABLE_TEMPLATE = htmlToElement(SKILL_LEVELS_INFO_TABLE_HTML); FORECAST_STYLES_TEMPLATE = htmlToElement(FORECAST_STYLES_HTML); } @@ -519,6 +523,26 @@ const TEAM_WINRATE_TABLE_HTML = /*language=HTML*/ ` `; +const PLAYER_REPUTATION_INLINE_HTML = /*language=HTML*/ ` +
+ + +
`; + +const PLAYER_REPUTATION_BADGE_HTML = /*language=HTML*/ ` +
+
+ 👎 Toxic + 👍 Friendly + — Neutral +
+
+ + + +
+
`; + const SKILL_LEVELS_INFO_TABLE_HTML = /*language=HTML*/ `
@@ -1797,5 +1821,133 @@ tr[class*=MatchHistoryTableRow] { justify-content: space-around; flex: 1 1 0; } + +.forecast-reputation-container { + margin-top: 8px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid rgb(36, 36, 36); + background: rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + gap: 6px; +} + +.forecast-reputation-label-wrap { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.forecast-reputation-label { + font-size: 11px; + opacity: 0.5; + transition: opacity 0.2s, font-size 0.2s; +} + +.forecast-reputation-label-current { + font-size: 13px; + font-weight: 600; + opacity: 1; +} + +.forecast-reputation-label-toxic.forecast-reputation-label-current { color: #ff4444; } +.forecast-reputation-label-friendly.forecast-reputation-label-current { color: #44ff44; } +.forecast-reputation-label-neutral.forecast-reputation-label-current { color: #aaa; } + +.forecast-reputation-buttons { + display: flex; + gap: 6px; +} + +.forecast-reputation-btn { + font-size: 11px; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid transparent; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.2s, border-color 0.2s; +} + +.forecast-reputation-btn:hover { + opacity: 1; +} + +.forecast-reputation-btn-toxic { + background: rgba(255, 68, 68, 0.25); + color: #ff4444; +} + +.forecast-reputation-btn-toxic:hover { + border-color: #ff4444; +} + +.forecast-reputation-btn-friendly { + background: rgba(68, 255, 68, 0.25); + color: #44ff44; +} + +.forecast-reputation-btn-friendly:hover { + border-color: #44ff44; +} + +.forecast-reputation-meet-count { + font-size: 10px; + color: #888; + margin-left: auto; +} + +.forecast-reputation-btn-reset { + background: rgba(128, 128, 128, 0.25); + color: #aaa; + font-size: 10px; +} + +.forecast-reputation-btn-reset:hover { + border-color: #888; +} + +.forecast-reputation-inline { + vertical-align: middle; +} + +.reputation-btn { + background: transparent; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 4px; + width: 20px; + height: 20px; + font-size: 12px; + cursor: pointer; + opacity: 0.3; + transition: all 0.2s; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.reputation-btn:hover { + opacity: 0.6; + transform: scale(1.1); +} + +.reputation-btn.active { + opacity: 1; + transform: scale(1.15); + border-width: 2px; +} + +.reputation-toxic.active { + border-color: #ff4444; + background: rgba(255, 68, 68, 0.2); +} + +.reputation-friendly.active { + border-color: #44ff44; + background: rgba(68, 255, 68, 0.2); +} `; diff --git a/src/faceit/room/matchroom.js b/src/faceit/room/matchroom.js index 76329ad..fa422c5 100644 --- a/src/faceit/room/matchroom.js +++ b/src/faceit/room/matchroom.js @@ -134,6 +134,72 @@ async function calculateStats(team, playerId, matchAmount) { const playerStats = teamMap.get(playerId); if (playerStats) displayPlayerStats(playerId, playerStats, table); + + try { + if (userCardElement.querySelector('.forecast-reputation-container[data-player-id]')) return; + + const rep = await getPlayerReputation(playerId); + const currentRating = rep ? rep.rating : REPUTATION_NEUTRAL; + const updatedAt = rep && rep.updatedAt ? rep.updatedAt : null; + + const badgeClone = PLAYER_REPUTATION_BADGE_TEMPLATE.cloneNode(true); + const container = badgeClone.querySelector('.forecast-reputation-container'); + if (!container) return; + container.setAttribute('data-player-id', playerId); + + const labelToxic = container.querySelector('[data-label="toxic"]'); + const labelFriendly = container.querySelector('[data-label="friendly"]'); + const labelNeutral = container.querySelector('[data-label="neutral"]'); + [labelToxic, labelFriendly, labelNeutral].forEach(el => el.classList.remove('forecast-reputation-label-current')); + const currentLabel = container.querySelector(`[data-label="${currentRating}"]`); + if (currentLabel) { + currentLabel.classList.add('forecast-reputation-label-current'); + if (updatedAt) { + const d = new Date(updatedAt); + currentLabel.setAttribute('title', 'Rated: ' + d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + } + } + + const btnToxic = container.querySelector('[data-reputation="toxic"]'); + const btnFriendly = container.querySelector('[data-reputation="friendly"]'); + const btnReset = container.querySelector('[data-reputation="reset"]'); + + function setReputationUI(rating) { + [labelToxic, labelFriendly, labelNeutral].forEach(el => el.classList.remove('forecast-reputation-label-current')); + const label = container.querySelector(`[data-label="${rating}"]`); + if (label) label.classList.add('forecast-reputation-label-current'); + } + + function setRatedTitle(el) { + if (el) el.setAttribute('title', 'Rated: ' + new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + } + btnToxic.addEventListener('click', async () => { + await setPlayerReputation(playerId, REPUTATION_TOXIC); + setReputationUI(REPUTATION_TOXIC); + setRatedTitle(labelToxic); + console.log('[FORECAST REPUTATION]', playerId, REPUTATION_TOXIC); + }); + btnFriendly.addEventListener('click', async () => { + await setPlayerReputation(playerId, REPUTATION_FRIENDLY); + setReputationUI(REPUTATION_FRIENDLY); + setRatedTitle(labelFriendly); + console.log('[FORECAST REPUTATION]', playerId, REPUTATION_FRIENDLY); + }); + if (btnReset) { + btnReset.addEventListener('click', async () => { + await resetPlayerReputation(playerId); + setReputationUI(REPUTATION_NEUTRAL); + const cur = container.querySelector(`[data-label="${REPUTATION_NEUTRAL}"]`); + if (cur) cur.removeAttribute('title'); + console.log('[FORECAST REPUTATION]', playerId, 'reset'); + }); + } + + appendTo(badgeClone, userCardElement); + matchRoomModule.removalNode(badgeClone); + } catch (err) { + error('Reputation badge failed', err); + } }); } @@ -177,7 +243,66 @@ async function displayWinRates(matchDetails) { const teamMatches = calculateTeamMatches(teamMap); displayTeamMatches(htmlResource, teamName, teamMatches); }); - }) + }); + + await setupInlineReputationForLobby(matchDetails); +} + +async function setupInlineReputationForLobby(matchDetails) { + const selectorMatchPlayer = 'div[class*=Overview__Grid] div[class*=ListContentPlayer__SlotWrapper] div[class*=styles__NicknameContainer] > div > div'; + + const nicknameToIdMap = new Map(); + [matchDetails.teams.faction1.roster, matchDetails.teams.faction2.roster].forEach(roster => { + roster.forEach(player => { + nicknameToIdMap.set(player.nickname, player.player_id); + }); + }); + + matchRoomModule.doAfterAllNodeAppear(selectorMatchPlayer, async (node) => { + const container = node.parentElement; + if (container.hasAttribute('data-processed-reputation')) return; + container.setAttribute('data-processed-reputation', 'true'); + + const nickname = node.textContent.trim(); + const playerId = nicknameToIdMap.get(nickname); + if (!playerId) return; + + try { + const reputation = await getPlayerReputation(playerId); + + const template = PLAYER_REPUTATION_INLINE_TEMPLATE.cloneNode(true); + const inlineEl = template.firstElementChild || template; + + if (reputation?.rating === REPUTATION_TOXIC || reputation?.rating === REPUTATION_FRIENDLY) { + const activeBtn = inlineEl.querySelector(`.reputation-${reputation.rating}`); + if (activeBtn) activeBtn.classList.add('active'); + } + + const toxicBtn = inlineEl.querySelector('.reputation-toxic'); + const friendlyBtn = inlineEl.querySelector('.reputation-friendly'); + + toxicBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + await setPlayerReputation(playerId, REPUTATION_TOXIC); + toxicBtn.classList.add('active'); + friendlyBtn.classList.remove('active'); + console.log('[REPUTATION INLINE]', playerId, nickname, 'toxic'); + }); + + friendlyBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + await setPlayerReputation(playerId, REPUTATION_FRIENDLY); + friendlyBtn.classList.add('active'); + toxicBtn.classList.remove('active'); + console.log('[REPUTATION INLINE]', playerId, nickname, 'friendly'); + }); + + container.appendChild(inlineEl); + matchRoomModule.removalNode(inlineEl); + } catch (err) { + error('Inline reputation failed', err); + } + }); } function addTableTeamTitle(htmlResource,roster, title) { diff --git a/src/faceit/service/profiles.js b/src/faceit/service/profiles.js index fd8dcf6..dcbc041 100644 --- a/src/faceit/service/profiles.js +++ b/src/faceit/service/profiles.js @@ -21,18 +21,20 @@ const profilesModule = new Module("profiles", () => { profilesModule.doAfterNodeAppearWhenVisible(selectorProfileTooltipV2, async (node) => { const nickname = node.textContent; const logo = await addFCUserLogoIfRegisteredByNick(getNthParent(node, 2), nickname, 20, 20, true); - logo.style.display = 'flex' - logo.style.alignItems = 'center' - logo.style.height = 'unset' + if (logo) { + logo.style.display = 'flex'; + logo.style.alignItems = 'center'; + logo.style.height = 'unset'; + } }); let selectorFriendsList = '[role="dialog"] > div[class*=FriendsMenu__MenuScrollArea] > div[class*=RosterList__FriendsHolder] > div > div > div > div > div[class*=User__UserContainer] > span' profilesModule.doAfterNodeAppearWhenVisible(selectorFriendsList, async (node) => { const nickname = node.textContent; - node.style.display = 'flex' - node.style.alignItems = 'center' + node.style.display = 'flex'; + node.style.alignItems = 'center'; const logo = await addFCUserLogoIfRegisteredByNick(node, nickname, 20, 20, true); - logo.style.paddingLeft = '4px' + if (logo) logo.style.paddingLeft = '4px'; }); if (lobby.pageType === "friends") {