A userscript with modded settings for the bonk collective server.
// ==UserScript==
// @name TBC Client (Lite)
// @namespace https://greasyfork.org/users/1552147-ansonii-crypto
// @version 1.3.2
// @description A userscript with modded settings for the bonk collective server.
// @match https://bonk.io/gameframe-release.html
// @run-at document-start
// @grant GM_xmlhttpRequest
// @connect api.github.com
// @license N/A
// ==/UserScript==
(() => {
'use strict';
function $(id) {
return document.getElementById(id);
}
function waitForElement(id, cb, timeoutMs = 30000) {
const start = Date.now();
const int = setInterval(() => {
const el = $(id);
if (el) {
clearInterval(int);
cb(el);
} else if (Date.now() - start > timeoutMs) {
clearInterval(int);
}
}, 200);
}
(() => {
const global = window;
global.bonkMods = global.bonkMods || {};
const bonkMods = global.bonkMods;
bonkMods._categories = bonkMods._categories || {};
bonkMods._blocks = bonkMods._blocks || [];
bonkMods._mods = bonkMods._mods || {};
const DEFAULT_CATEGORY_ID = 'general';
const FALLBACK_MOD_ID = 'other';
let currentCategoryId = null;
let currentModId = 'all';
bonkMods.registerMod = function registerMod(meta) {
if (!meta || !meta.id) return;
const id = meta.id;
const existing = bonkMods._mods[id] || {};
bonkMods._mods[id] = Object.assign(
{
id,
name: id,
version: '',
author: '',
description: '',
homepage: '',
devHint: '',
},
existing,
meta
);
renderModDropdown();
renderModInfo();
renderCategories();
renderBlocks();
};
bonkMods.registerCategory = function registerCategory(def) {
if (!def || !def.id) return;
if (!def.label) def.label = def.id;
if (typeof def.order !== 'number') def.order = 100;
if (!bonkMods._categories[def.id]) {
bonkMods._categories[def.id] = { id: def.id, label: def.label, order: def.order };
} else {
Object.assign(bonkMods._categories[def.id], def);
}
renderCategories();
renderBlocks();
};
bonkMods.addBlock = function addBlock(def) {
if (!def || !def.id || typeof def.render !== 'function') return;
if (!def.categoryId) def.categoryId = DEFAULT_CATEGORY_ID;
if (!def.title) def.title = '';
if (typeof def.order !== 'number') def.order = 100;
if (!def.modId) def.modId = FALLBACK_MOD_ID;
if (bonkMods._blocks.some((b) => b.id === def.id)) return;
bonkMods._blocks.push(def);
if (!bonkMods._categories[def.categoryId]) {
bonkMods.registerCategory({
id: def.categoryId,
label: def.categoryId.charAt(0).toUpperCase() + def.categoryId.slice(1),
order: 100,
});
}
if (!bonkMods._mods[def.modId] && def.modId === FALLBACK_MOD_ID) {
bonkMods.registerMod({
id: FALLBACK_MOD_ID,
name: 'Other Mods',
description: 'Blocks from mods that did not register themselves.',
});
}
renderModDropdown();
renderCategories();
renderBlocks();
};
bonkMods.addModdedBlock = function addModdedBlock(def) {
if (!def) return;
bonkMods.addBlock(Object.assign({ categoryId: DEFAULT_CATEGORY_ID }, def));
};
function setupSettingsShell(settingsContainer) {
const topBar = $('settings_topBar');
const closeBtn = $('settings_close');
if (!topBar || !closeBtn) return;
if ($('mod_tabs')) {
renderModDropdown();
renderModInfo();
renderCategories();
renderBlocks();
return;
}
topBar.style.display = 'flex';
topBar.style.alignItems = 'center';
topBar.style.padding = '0 10px';
topBar.style.boxSizing = 'border-box';
const title = document.createElement('div');
title.textContent = 'Settings';
title.style.fontWeight = 'bold';
title.style.marginRight = '12px';
const tabs = document.createElement('div');
tabs.id = 'mod_tabs';
tabs.style.display = 'flex';
tabs.style.gap = '6px';
tabs.style.margin = '0 auto';
tabs.innerHTML = `
<div class="brownButton brownButton_classic buttonShadow mod_tab active" data-tab="generic">
Generic
</div>
<div class="brownButton brownButton_classic buttonShadow mod_tab mod_tab_modded" data-tab="modded">
Modded <span id="mod_tab_modname" style="font-weight:normal;opacity:0.7;"></span> ▾
</div>
`;
closeBtn.style.position = 'static';
closeBtn.style.marginLeft = 'auto';
topBar.textContent = '';
topBar.appendChild(title);
topBar.appendChild(tabs);
topBar.appendChild(closeBtn);
if (!$('bonk_mod_core_css')) {
const style = document.createElement('style');
style.id = 'bonk_mod_core_css';
style.textContent = `
.mod_tab {
padding: 4px 10px !important;
font-size: 13px;
line-height: normal;
height: auto !important;
opacity: 0.75;
position: relative;
}
.mod_tab.active { opacity: 1; }
#mod_dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: rgba(16, 27, 38, 0.98);
border-radius: 6px;
box-shadow: 0 8px 20px rgba(0,0,0,0.6);
padding: 6px;
min-width: 200px;
z-index: 100000;
display: none;
}
#mod_dropdown_title {
font-size: 11px;
text-transform: uppercase;
opacity: .8;
margin-bottom: 4px;
}
.mod_dropdown_item {
font-size: 12px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.mod_dropdown_item small { opacity: .7; font-size: 10px; }
.mod_dropdown_item:hover { background: rgba(255,255,255,0.08); }
.mod_dropdown_item.active { background: rgba(121,85,248,0.4); }
#mod_modded_settings {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
margin-top: 4px;
}
#mod_modinfo {
padding: 4px 7px 6px 7px;
margin-bottom: 6px;
border-radius: 4px;
background: rgba(0,0,0,0.18);
font-size: 11px;
}
#mod_modinfo_title { font-weight: bold; font-size: 12px; }
#mod_modinfo_meta { opacity: .8; margin: 1px 0 3px 0; }
#mod_modinfo_desc { opacity: .9; }
#mod_modinfo_link a { color: #9fd4ff; text-decoration: underline; }
#mod_cat_tabs {
display: flex;
gap: 6px;
margin: 4px 0 6px 0;
flex-wrap: wrap;
}
.mod_cat_tab {
padding: 3px 9px !important;
font-size: 12px;
height: auto !important;
cursor: pointer;
opacity: .75;
}
.mod_cat_tab.active {
opacity: 1;
outline: 1px solid rgba(255,255,255,0.25);
}
#mod_blocks_scroll {
position: relative;
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
padding-right: 6px;
border-radius: 4px;
background: rgba(0,0,0,0.1);
scrollbar-width: thin;
scrollbar-color: #32485d rgba(0,0,0,0.25);
}
#mod_blocks_scroll::-webkit-scrollbar { width: 8px; }
#mod_blocks_scroll::-webkit-scrollbar-track {
background: rgba(0,0,0,0.25);
border-radius: 4px;
}
#mod_blocks_scroll::-webkit-scrollbar-thumb {
background: linear-gradient(#32485d, #182430);
border-radius: 4px;
border: 1px solid rgba(255,255,255,0.15);
}
#mod_blocks_scroll::-webkit-scrollbar-thumb:hover {
background: linear-gradient(#3e566d, #1f3140);
}
.mod_block {
padding: 8px 6px 10px 6px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.mod_block:first-child { border-top: 1px solid rgba(255,255,255,0.08); }
.mod_block_title { font-weight: bold; font-size: 13px; }
.mod_block_sub {
font-size: 11px;
opacity: .8;
margin-top: 2px;
margin-bottom: 6px;
}
.tbc_board {
border-radius: 10px;
padding: 10px 10px 9px 10px;
background: linear-gradient(180deg, rgba(121,85,248,0.18), rgba(0,0,0,0.15));
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 6px 16px rgba(0,0,0,0.35);
}
.tbc_board_header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.tbc_board_title {
font-weight: 800;
font-size: 14px;
letter-spacing: 0.2px;
}
.tbc_board_badge {
font-size: 10px;
opacity: 0.85;
padding: 2px 6px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.18);
white-space: nowrap;
}
.tbc_board_section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255,255,255,0.08);
}
.tbc_board_h {
font-weight: 700;
font-size: 12px;
margin-bottom: 4px;
}
.tbc_board_p {
font-size: 11px;
opacity: 0.9;
line-height: 1.35;
}
.tbc_board_list {
margin: 6px 0 0 0;
padding-left: 16px;
font-size: 11px;
opacity: 0.92;
line-height: 1.35;
}
.tbc_board_link {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid rgba(159,212,255,0.35);
background: rgba(0,0,0,0.18);
color: #9fd4ff;
text-decoration: none;
font-size: 11px;
}
.tbc_board_link:hover {
background: rgba(159,212,255,0.08);
border-color: rgba(159,212,255,0.55);
}
.tbc_board_note {
margin-top: 6px;
font-size: 10px;
opacity: 0.75;
}
`;
document.head.appendChild(style);
}
const genericWrap = document.createElement('div');
genericWrap.id = 'mod_generic_settings';
const moddedWrap = document.createElement('div');
moddedWrap.id = 'mod_modded_settings';
moddedWrap.style.display = 'none';
moddedWrap.style.padding = '10px 10px 6px 10px';
const modInfo = document.createElement('div');
modInfo.id = 'mod_modinfo';
modInfo.innerHTML = `
<div id="mod_modinfo_title"></div>
<div id="mod_modinfo_meta"></div>
<div id="mod_modinfo_desc"></div>
<div id="mod_modinfo_link"></div>
`;
const catTabs = document.createElement('div');
catTabs.id = 'mod_cat_tabs';
const blocksScroll = document.createElement('div');
blocksScroll.id = 'mod_blocks_scroll';
moddedWrap.appendChild(modInfo);
moddedWrap.appendChild(catTabs);
moddedWrap.appendChild(blocksScroll);
[...settingsContainer.children].forEach((el) => {
if (
el.id !== 'settings_topBar' &&
el.id !== 'settings_close' &&
el.id !== 'settings_cancelButton' &&
el.id !== 'settings_saveButton'
) {
genericWrap.appendChild(el);
}
});
settingsContainer.insertBefore(genericWrap, settingsContainer.children[1]);
settingsContainer.insertBefore(moddedWrap, $('settings_cancelButton'));
tabs.querySelectorAll('.mod_tab').forEach((tab) => {
tab.addEventListener('click', (e) => {
const which = tab.dataset.tab;
tabs.querySelectorAll('.mod_tab').forEach((t) => t.classList.toggle('active', t === tab));
const isModded = which === 'modded';
genericWrap.style.display = isModded ? 'none' : 'block';
moddedWrap.style.display = isModded ? 'flex' : 'none';
if (isModded) {
e.stopPropagation();
hideModDropdown();
} else {
hideModDropdown();
}
});
});
const moddedTab = topBar.querySelector('.mod_tab_modded');
const dropdown = document.createElement('div');
dropdown.id = 'mod_dropdown';
dropdown.innerHTML = `
<div id="mod_dropdown_title">Select mod</div>
<div id="mod_dropdown_list"></div>
`;
moddedTab.style.position = 'relative';
moddedTab.appendChild(dropdown);
document.addEventListener('click', (e) => {
if (!dropdown.contains(e.target) && !moddedTab.contains(e.target)) hideModDropdown();
});
bonkMods.registerCategory({ id: DEFAULT_CATEGORY_ID, label: 'General', order: 0 });
bonkMods._mods.all = {
id: 'all',
name: 'All Mods',
description: 'Show settings for every registered mod.',
version: '1.3.2',
author: 'SIoppy',
homepage: '',
};
bonkMods.registerMod({
id: '__tbc',
name: "The Bonk Collective (TBC)",
version: '1.3.2',
author: 'SIoppy',
description: 'About this script, its purpose, and community links.',
homepage: 'https://discord.gg/RUm7wZHrHu',
devHint: 'Community-driven Bonk.io quality-of-life tools.',
});
bonkMods.registerCategory({ id: '__tbc_info', label: 'Information', order: 999 });
bonkMods.addBlock({
id: '__tbc_board',
modId: '__tbc',
categoryId: '__tbc_info',
title: 'TBC Info Board',
order: 0,
render(container) {
container.innerHTML = `
<div class="tbc_board">
<div class="tbc_board_header">
<div class="tbc_board_title">The Bonk Collective (TBC)</div>
<div class="tbc_board_badge">Script Info</div>
</div>
<div class="tbc_board_p">
This userscript bundles a modded Settings UI plus community features (like Re:Color + preset colours) to make Bonk.io
customization easier and more organized.
</div>
<div class="tbc_board_section">
<div class="tbc_board_h">Intended purpose</div>
<ul class="tbc_board_list">
<li>Provide a clean “Modded Settings” hub for scripts.</li>
<li>Ship curated QoL tools under one umbrella.</li>
<li>Keep settings grouped, consistent, and easy to manage.</li>
</ul>
</div>
<div class="tbc_board_section">
<div class="tbc_board_h">Discord connection</div>
<div class="tbc_board_p">
TBC coordinates updates, feedback, and feature requests through Discord.
</div>
<a class="tbc_board_link" href="https://discord.gg/RUm7wZHrHu" target="_blank" rel="noopener">
<span style="font-weight:700;">Join the TBC Discord</span>
<span style="opacity:.75;">(invite)</span>
</a>
<div class="tbc_board_note">
Tip: If you’re in guest mode, some settings may be temporary.
</div>
</div>
</div>
`;
},
});
global.dispatchEvent(new Event('bonkModsReady'));
renderModDropdown();
renderModInfo();
renderCategories();
renderBlocks();
}
function toggleModDropdown() {
const dd = $('mod_dropdown');
if (!dd) return;
dd.style.display = dd.style.display === 'none' || dd.style.display === '' ? 'block' : 'none';
}
function hideModDropdown() {
const dd = $('mod_dropdown');
if (!dd) return;
dd.style.display = 'none';
}
function renderModDropdown() {
const listEl = $('mod_dropdown_list');
const modNameSpan = $('mod_tab_modname');
if (!listEl || !bonkMods._mods) return;
if (!bonkMods._mods.all) {
bonkMods._mods.all = { id: 'all', name: 'All Mods', description: '', version: '', author: '' };
}
if (!currentModId || !bonkMods._mods[currentModId]) currentModId = 'all';
const modsArr = Object.values(bonkMods._mods);
if (!modsArr.length) return;
modsArr.sort((a, b) => {
if (a.id === 'all') return -1;
if (b.id === 'all') return 1;
if ((a.name || '') === (b.name || '')) return a.id > b.id ? 1 : -1;
return (a.name || '').localeCompare(b.name || '');
});
listEl.textContent = '';
modsArr.forEach((mod) => {
const item = document.createElement('div');
item.className = `mod_dropdown_item${mod.id === currentModId ? ' active' : ''}`;
item.dataset.modId = mod.id;
item.innerHTML = `<span>${mod.name || mod.id}</span><small>${mod.version || ''}</small>`;
item.addEventListener('click', (e) => {
e.stopPropagation();
currentModId = mod.id;
renderModDropdown();
renderModInfo();
renderCategories();
renderBlocks();
hideModDropdown();
});
listEl.appendChild(item);
});
if (modNameSpan && bonkMods._mods[currentModId]) {
const label = currentModId === 'all' ? '' : `(${bonkMods._mods[currentModId].name})`;
modNameSpan.textContent = label;
}
}
function renderModInfo() {
const mod = bonkMods._mods[currentModId] || bonkMods._mods.all;
const t = $('mod_modinfo_title');
const m = $('mod_modinfo_meta');
const d = $('mod_modinfo_desc');
const l = $('mod_modinfo_link');
if (!t || !m || !d || !l || !mod) return;
t.textContent = mod.name || 'All Mods';
m.textContent = [mod.version ? `v${mod.version}` : '', mod.author ? `by ${mod.author}` : '']
.filter(Boolean)
.join(' · ');
d.textContent = '';
const desc = document.createElement('div');
desc.textContent = mod.description || '';
d.appendChild(desc);
if (mod.id === '__tbc' && mod.devHint) {
const extra = document.createElement('div');
extra.style.fontSize = '10px';
extra.style.opacity = '0.8';
extra.style.marginTop = '3px';
extra.textContent = mod.devHint;
d.appendChild(extra);
}
if (mod.homepage) {
l.innerHTML = `<a href="${mod.homepage}" target="_blank" rel="noopener">Open page</a>`;
} else {
l.textContent = '';
}
}
function renderCategories() {
const catTabs = $('mod_cat_tabs');
if (!catTabs) return;
const blocks = bonkMods._blocks || [];
const usedCatIds = new Set();
blocks.forEach((b) => {
if (currentModId === 'all' || b.modId === currentModId) usedCatIds.add(b.categoryId);
});
const allCats = Object.values(bonkMods._categories).filter((c) => usedCatIds.has(c.id));
if (!allCats.length) {
catTabs.textContent = '';
currentCategoryId = null;
const scroll = $('mod_blocks_scroll');
if (scroll) scroll.textContent = '';
return;
}
allCats.sort((a, b) => (a.order === b.order ? a.label.localeCompare(b.label) : a.order - b.order));
if (!currentCategoryId || !usedCatIds.has(currentCategoryId)) currentCategoryId = allCats[0].id;
catTabs.textContent = '';
allCats.forEach((cat) => {
const btn = document.createElement('div');
btn.className = `brownButton brownButton_classic buttonShadow mod_cat_tab${
cat.id === currentCategoryId ? ' active' : ''
}`;
btn.dataset.catId = cat.id;
btn.textContent = cat.label;
btn.addEventListener('click', () => {
currentCategoryId = cat.id;
renderCategories();
renderBlocks();
});
catTabs.appendChild(btn);
});
}
function renderBlocks() {
const scroll = $('mod_blocks_scroll');
if (!scroll) return;
const blocks = bonkMods._blocks || [];
scroll.textContent = '';
scroll.scrollTop = 0;
if (!blocks.length || !currentCategoryId) return;
const arr = blocks
.slice()
.sort((a, b) => {
const ca = bonkMods._categories[a.categoryId] || { order: 999 };
const cb = bonkMods._categories[b.categoryId] || { order: 999 };
if (ca.order !== cb.order) return ca.order - cb.order;
if (a.order === b.order) return a.id > b.id ? 1 : -1;
return a.order - b.order;
});
arr.forEach((def) => {
if (def.categoryId !== currentCategoryId) return;
if (currentModId !== 'all' && def.modId !== currentModId) return;
const block = document.createElement('div');
block.className = 'mod_block';
block.dataset.blockId = def.id;
if (def.title) {
const titleEl = document.createElement('div');
titleEl.className = 'mod_block_title';
titleEl.textContent = def.title;
block.appendChild(titleEl);
}
const content = document.createElement('div');
block.appendChild(content);
scroll.appendChild(block);
try {
def.render(content);
} catch (e) {
console.error('[BonkModSettingsCore] error rendering block', def.id, e);
content.textContent = 'Error loading this mod block.';
}
});
}
waitForElement('settingsContainer', setupSettingsShell);
})();
(() => {
let colorGroups = [];
const STORAGE_KEY_PREFIX_V2 = 'bonk_recolor_groups_v2_';
const STORAGE_KEY_PREFIX_V1 = 'bonk_mod_color_groups_';
const DISPLAY_SETTINGS_KEY_PREFIX_V1 = 'bonk_recolor_display_toggles_v1_';
let storageKey = null;
let lastStorageKey = undefined;
let observersInitialized = false;
let activePanel = null;
let selectedPlayer = null;
let refreshRecolorSettingsUi = null;
let recolorUiSyncLocked = false;
let recolorSyncLockListenerInstalled = false;
function createDefaultRecolorDisplaySettings() {
return {
playerNames: true,
winnerBoard: false,
ingameChatNames: false,
lobbyChatNames: false,
playerListNames: false,
playerListBackboard: true,
};
}
let recolorDisplaySettings = createDefaultRecolorDisplaySettings();
const COLOR_PRESETS = [
{ id: 'blue', label: 'BLUE', color: '#448aff' },
{ id: 'red', label: 'RED', color: '#d32e2f' },
{ id: 'green', label: 'GREEN', color: '#177818' },
{ id: 'yellow', label: 'YELLOW', color: '#fff93d' },
{ id: 'ffa', label: 'FFA', color: '#1abc9c' },
{ id: 'orange', label: 'ORANGE', color: '#ff8a00' },
{ id: 'pink_purple', label: 'PINK-PURPLE', color: '#d35bff' },
{ id: 'turquoise', label: 'PINK', color: '#ff69b4' },
];
function isPresetColor(hex) {
const h = String(hex || '').trim().toLowerCase();
return COLOR_PRESETS.some((p) => p.color.toLowerCase() === h);
}
function getRandomPresetColor() {
const idx = Math.floor(Math.random() * COLOR_PRESETS.length);
return COLOR_PRESETS[idx]?.color || '#ff0000';
}
let colorCache = new Map();
window.addEventListener('recolorGroupsChanged', () => colorCache.clear());
let sharedColorCache = new Map();
let lastSharedColorSig = '';
function getLobbyHostNameNormForRecolor() {
const badges = Array.from(document.querySelectorAll('.newbonklobby_playerentry_host'));
let fallbackName = '';
for (const hostBadge of badges) {
const src = String(hostBadge.getAttribute('src') || hostBadge.src || '').toLowerCase();
const row = hostBadge.closest('.newbonklobby_playerentry');
if (!row) continue;
const nameEl = row.querySelector('.newbonklobby_playerentry_name');
if (!nameEl) continue;
const nameNorm = normalizeName(nameEl.textContent || '');
if (!nameNorm) continue;
if (!fallbackName) fallbackName = nameNorm;
if (src && src.indexOf('host_5.png') !== -1) return nameNorm;
}
return fallbackName;
}
function isSelfLobbyHostForRecolor() {
const selfNameEl = $('pretty_top_name');
const selfNorm = normalizeName(selfNameEl ? selfNameEl.textContent : '');
const hostNorm = getLobbyHostNameNormForRecolor();
return !!selfNorm && !!hostNorm && selfNorm === hostNorm;
}
function rebuildSharedColorCacheIfNeeded() {
const snapshot = Array.isArray(window.tbcSharedGroupsSnapshot) ? window.tbcSharedGroupsSnapshot : [];
const sig = JSON.stringify(snapshot);
if (sig === lastSharedColorSig) return;
lastSharedColorSig = sig;
sharedColorCache.clear();
snapshot.forEach((g) => {
if (!g || typeof g !== 'object') return;
const color = String(g.color || '').trim();
if (!color) return;
const players = Array.isArray(g.players) ? g.players : [];
players.forEach((p) => {
const name = typeof p === 'string' ? p : (p && p.name ? p.name : '');
const key = normalizeName(name);
const canonKey = normalizeWinnerLookupName(name);
const memberType = getGroupPlayerMemberType(p);
const keyTyped = key ? `${key}|${getLookupTypeSuffix(memberType)}` : '';
const canonTyped = canonKey ? `${canonKey}|${getLookupTypeSuffix(memberType)}` : '';
if (keyTyped && !sharedColorCache.has(keyTyped)) sharedColorCache.set(keyTyped, color);
if (canonTyped && !sharedColorCache.has(canonTyped)) sharedColorCache.set(canonTyped, color);
if (memberType === 'account') {
if (key && !sharedColorCache.has(key)) sharedColorCache.set(key, color);
if (canonKey && !sharedColorCache.has(canonKey)) sharedColorCache.set(canonKey, color);
}
});
});
}
function hasDisplayGroupsForNames() {
rebuildSharedColorCacheIfNeeded();
const hasShared = !!window.tbcRoomGroupsSyncActive;
if (hasShared) return sharedColorCache.size > 0;
return Array.isArray(colorGroups) && colorGroups.length > 0;
}
function getDisplayColorForName(name, opts = null) {
const memberType = normalizeMemberType(opts && opts.memberType);
rebuildSharedColorCacheIfNeeded();
const hasShared = !!window.tbcRoomGroupsSyncActive;
if (hasShared) {
const key = normalizeName(name);
const canonKey = normalizeWinnerLookupName(name);
if (memberType !== 'any') {
const typedKey = key ? `${key}|${getLookupTypeSuffix(memberType)}` : '';
const typedCanon = canonKey ? `${canonKey}|${getLookupTypeSuffix(memberType)}` : '';
if (typedKey && sharedColorCache.has(typedKey)) return sharedColorCache.get(typedKey) || null;
if (typedCanon && sharedColorCache.has(typedCanon)) return sharedColorCache.get(typedCanon) || null;
return null;
}
if (key && sharedColorCache.has(key)) return sharedColorCache.get(key) || null;
if (canonKey && sharedColorCache.has(canonKey)) return sharedColorCache.get(canonKey) || null;
const accountKey = key ? `${key}|account` : '';
const accountCanon = canonKey ? `${canonKey}|account` : '';
if (accountKey && sharedColorCache.has(accountKey)) return sharedColorCache.get(accountKey) || null;
if (accountCanon && sharedColorCache.has(accountCanon)) return sharedColorCache.get(accountCanon) || null;
const guestKey = key ? `${key}|guest` : '';
const guestCanon = canonKey ? `${canonKey}|guest` : '';
if (guestKey && sharedColorCache.has(guestKey)) return sharedColorCache.get(guestKey) || null;
if (guestCanon && sharedColorCache.has(guestCanon)) return sharedColorCache.get(guestCanon) || null;
return null;
}
return getColorForName(name, opts);
}
function canonicalizeWinnerName(nameText) {
const raw = String(nameText || '')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim();
if (!raw) return '';
const parts = raw.split(/\s+/).filter(Boolean);
if (parts.length >= 3 && parts.every((p) => p.length === 1)) return parts.join('');
return raw;
}
function normalizeWinnerLookupName(nameText) {
return normalizeName(canonicalizeWinnerName(nameText));
}
function getWinnerBoardColorForName(name, opts = null) {
const hasSharedSync = !!window.tbcRoomGroupsSyncActive;
const sharedColor = getDisplayColorForName(name, opts);
if (sharedColor) return sharedColor;
const canonical = canonicalizeWinnerName(name);
if (canonical && canonical !== String(name || '')) {
const sharedCanonical = getDisplayColorForName(canonical, opts);
if (sharedCanonical) return sharedCanonical;
}
if (hasSharedSync) return null;
const local = getColorForName(name, opts);
if (local) return local;
if (canonical && canonical !== String(name || '')) return getColorForName(canonical, opts);
return null;
}
let lobbyScanQueued = false;
let lobbyPlayerMenuOwnerNameNorm = '';
let lobbyPlayerMenuPendingOwnerNameNorm = '';
let lobbyPlayerMenuOwnerTrackingInstalled = false;
const lobbyPersistentColorByName = new Map();
const lobbyPersistentColorVersionByName = new Map();
let lobbyPersistentColorEpoch = 1;
let lobbyPlayerGroupsActionMenuEl = null;
let lobbyPlayerGroupsTargetMenuEl = null;
let lobbyPlayerGroupsPickerMenuEl = null;
let lobbyPlayerGroupsPositionSyncInstalled = false;
function scheduleLobbyScan() {
if (lobbyScanQueued) return;
lobbyScanQueued = true;
const run = () => {
lobbyScanQueued = false;
applyLobbyNameColors(false);
};
if (typeof queueMicrotask === 'function') queueMicrotask(run);
else Promise.resolve().then(run);
}
function ensureLobbyPlayerGroupsMenuStyles() {
if ($('tbc_player_groups_menu_css')) return;
const style = document.createElement('style');
style.id = 'tbc_player_groups_menu_css';
style.textContent = `
.tbc_player_groups_target_menu {
max-height: 220px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(115, 78, 63, 0.92) rgba(170, 187, 196, 0.75);
}
.tbc_player_groups_action_menu .newbonklobby_playerentry_menu_button,
.tbc_player_groups_target_menu .newbonklobby_playerentry_menu_button {
width: 100%;
box-sizing: border-box;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-move-list="1"] {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-content: flex-start;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-action="back"] {
width: 100%;
margin-bottom: 4px;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-target] {
width: calc((100% - 8px) / 3);
min-width: 0;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-target-create] {
width: calc((100% - 8px) / 3);
min-width: 0;
border: 1px dashed rgba(255, 255, 255, 0.6);
border-radius: 8px;
background: transparent !important;
color: rgba(255, 255, 255, 0.92) !important;
text-shadow: none !important;
font-weight: 700;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-target-create]:hover,
.tbc_player_groups_target_menu [data-tbc-player-groups-target-create]:active {
background: rgba(127, 97, 81, 0.55) !important;
border-color: rgba(255, 255, 255, 0.78);
}
.tbc_player_groups_target_menu [data-tbc-player-groups-create-preset] {
width: calc((100% - 8px) / 3);
min-width: 0;
color: transparent !important;
text-shadow: none !important;
background: var(--tbc-group-color, #7f6151) !important;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-create-preset][data-selected="1"] {
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.8), 0 0 0 1px rgba(60,40,32,0.85);
}
.tbc_player_groups_target_menu [data-tbc-player-groups-action="create-cancel"]:hover,
.tbc_player_groups_target_menu [data-tbc-player-groups-action="create-cancel"]:active {
background: #b75545 !important;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-target],
.tbc_player_groups_target_menu [data-tbc-player-groups-target]:hover,
.tbc_player_groups_target_menu [data-tbc-player-groups-target]:active {
background: var(--tbc-group-color, #7f6151) !important;
color: transparent !important;
text-shadow: none !important;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-target]:hover {
filter: brightness(1.08);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.45);
}
.tbc_player_groups_target_menu [data-tbc-player-groups-move-list="1"]::-webkit-scrollbar {
width: 11px;
height: 11px;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-move-list="1"]::-webkit-scrollbar-track {
background: rgba(170, 187, 196, 0.75);
border-left: 1px solid rgba(78, 96, 106, 0.55);
}
.tbc_player_groups_target_menu [data-tbc-player-groups-move-list="1"]::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #8f6756, #6f4a3e);
border: 1px solid rgba(48, 34, 29, 0.9);
border-radius: 7px;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-move-list="1"]::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #9f7562, #7b5345);
}
.tbc_player_groups_target_menu [data-tbc-player-groups-move-list="1"]::-webkit-scrollbar-button:single-button {
background-color: rgba(170, 187, 196, 0.75);
border-left: 1px solid rgba(78, 96, 106, 0.55);
background-repeat: no-repeat;
background-position: center;
background-size: 7px 7px;
}
.tbc_player_groups_target_menu [data-tbc-player-groups-move-list="1"]::-webkit-scrollbar-button:single-button:vertical:decrement {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='7' height='7' viewBox='0 0 7 7'><path d='M3.5 1 L6 4.5 H1 Z' fill='%235e473d'/></svg>");
}
.tbc_player_groups_target_menu [data-tbc-player-groups-move-list="1"]::-webkit-scrollbar-button:single-button:vertical:increment {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='7' height='7' viewBox='0 0 7 7'><path d='M1 2.5 H6 L3.5 6 Z' fill='%235e473d'/></svg>");
}
`;
document.head.appendChild(style);
}
function closeLobbyPlayerGroupsMenus() {
wipeLobbyPlayerEntryMenus();
if (lobbyPlayerGroupsPickerMenuEl && lobbyPlayerGroupsPickerMenuEl.parentNode) {
lobbyPlayerGroupsPickerMenuEl.parentNode.removeChild(lobbyPlayerGroupsPickerMenuEl);
}
if (lobbyPlayerGroupsTargetMenuEl && lobbyPlayerGroupsTargetMenuEl.parentNode) {
lobbyPlayerGroupsTargetMenuEl.parentNode.removeChild(lobbyPlayerGroupsTargetMenuEl);
}
if (lobbyPlayerGroupsActionMenuEl && lobbyPlayerGroupsActionMenuEl.parentNode) {
lobbyPlayerGroupsActionMenuEl.parentNode.removeChild(lobbyPlayerGroupsActionMenuEl);
}
lobbyPlayerGroupsPickerMenuEl = null;
lobbyPlayerGroupsTargetMenuEl = null;
lobbyPlayerGroupsActionMenuEl = null;
setLobbyGroupsActionMenuMasked(false);
}
function setLobbyMainPlayerMenuMasked(active) {
const mainMenu = document.querySelector('.newbonklobby_playerentry_menu');
if (!(mainMenu instanceof Element)) return;
if (active) {
if (!mainMenu.hasAttribute('data-tbc-orig-opacity')) {
mainMenu.setAttribute('data-tbc-orig-opacity', String(mainMenu.style.opacity || ''));
}
if (!mainMenu.hasAttribute('data-tbc-orig-pointer-events')) {
mainMenu.setAttribute('data-tbc-orig-pointer-events', String(mainMenu.style.pointerEvents || ''));
}
mainMenu.style.opacity = '0';
mainMenu.style.pointerEvents = 'none';
return;
}
const origOpacity = mainMenu.getAttribute('data-tbc-orig-opacity');
const origPointer = mainMenu.getAttribute('data-tbc-orig-pointer-events');
if (origOpacity !== null) {
if (origOpacity) mainMenu.style.opacity = origOpacity;
else mainMenu.style.removeProperty('opacity');
mainMenu.removeAttribute('data-tbc-orig-opacity');
}
if (origPointer !== null) {
if (origPointer) mainMenu.style.pointerEvents = origPointer;
else mainMenu.style.removeProperty('pointer-events');
mainMenu.removeAttribute('data-tbc-orig-pointer-events');
}
}
function setLobbyGroupsActionMenuMasked(active) {
const actionMenu = lobbyPlayerGroupsActionMenuEl;
if (!(actionMenu instanceof Element)) return;
if (active) {
if (!actionMenu.hasAttribute('data-tbc-orig-opacity')) {
actionMenu.setAttribute('data-tbc-orig-opacity', String(actionMenu.style.opacity || ''));
}
if (!actionMenu.hasAttribute('data-tbc-orig-pointer-events')) {
actionMenu.setAttribute('data-tbc-orig-pointer-events', String(actionMenu.style.pointerEvents || ''));
}
actionMenu.style.opacity = '0';
actionMenu.style.pointerEvents = 'none';
return;
}
const origOpacity = actionMenu.getAttribute('data-tbc-orig-opacity');
const origPointer = actionMenu.getAttribute('data-tbc-orig-pointer-events');
if (origOpacity !== null) {
if (origOpacity) actionMenu.style.opacity = origOpacity;
else actionMenu.style.removeProperty('opacity');
actionMenu.removeAttribute('data-tbc-orig-opacity');
}
if (origPointer !== null) {
if (origPointer) actionMenu.style.pointerEvents = origPointer;
else actionMenu.style.removeProperty('pointer-events');
actionMenu.removeAttribute('data-tbc-orig-pointer-events');
}
}
function wipeLobbyPlayerEntryMenus() {
const mainMenu = document.querySelector('.newbonklobby_playerentry_menu');
if (mainMenu && mainMenu.style) {
mainMenu.style.display = 'none';
mainMenu.style.visibility = 'hidden';
}
const submenu = document.querySelector('.newbonklobby_playerentry_menu_submenu');
if (submenu && submenu.style) {
submenu.style.display = 'none';
submenu.style.visibility = 'hidden';
}
const highlighted = document.querySelector('.newbonklobby_playerentry_menuhighlighted');
if (highlighted && highlighted.classList) highlighted.classList.remove('newbonklobby_playerentry_menuhighlighted');
}
function closeLobbyPlayerMenusAll() {
closeLobbyPlayerGroupsMenus();
wipeLobbyPlayerEntryMenus();
lobbyPlayerMenuOwnerNameNorm = '';
lobbyPlayerMenuPendingOwnerNameNorm = '';
}
function positionLobbyFloatingMenu(menuEl, left, top) {
if (!menuEl || !(menuEl instanceof Element)) return;
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
const pad = 6;
let x = Math.round(left);
let y = Math.round(top);
const rect = menuEl.getBoundingClientRect();
if (x + rect.width > vw - pad) x = Math.max(pad, vw - rect.width - pad);
if (y + rect.height > vh - pad) y = Math.max(pad, vh - rect.height - pad);
if (x < pad) x = pad;
if (y < pad) y = pad;
menuEl.style.left = `${x}px`;
menuEl.style.top = `${y}px`;
}
function positionLobbyGroupsMenuLikeBonkDefault(menuEl, fallbackAnchorEl = null) {
if (!menuEl || !(menuEl instanceof Element)) return;
const nativeSub = Array.from(document.querySelectorAll('.newbonklobby_playerentry_menu_submenu')).find((el) => (
el instanceof Element &&
!el.hasAttribute('data-tbc-player-groups-action-menu') &&
!el.hasAttribute('data-tbc-player-groups-target-menu')
));
if (nativeSub) {
const rect = nativeSub.getBoundingClientRect();
menuEl.style.width = `${Math.max(1, Math.round(rect.width))}px`;
menuEl.style.boxSizing = 'border-box';
positionLobbyFloatingMenu(menuEl, rect.left, rect.top);
return;
}
const mainMenu = document.querySelector('.newbonklobby_playerentry_menu');
if (mainMenu instanceof Element) {
const rect = mainMenu.getBoundingClientRect();
menuEl.style.width = `${Math.max(1, Math.round(rect.width))}px`;
menuEl.style.boxSizing = 'border-box';
positionLobbyFloatingMenu(menuEl, rect.left, rect.top);
return;
}
if (fallbackAnchorEl instanceof Element) {
const rect = fallbackAnchorEl.getBoundingClientRect();
menuEl.style.width = `${Math.max(1, Math.round(rect.width))}px`;
menuEl.style.boxSizing = 'border-box';
positionLobbyFloatingMenu(menuEl, rect.left, rect.top);
}
}
function ensureLobbyPlayerGroupsPositionSync() {
if (lobbyPlayerGroupsPositionSyncInstalled) return;
lobbyPlayerGroupsPositionSyncInstalled = true;
const sync = () => {
if (!(lobbyPlayerGroupsActionMenuEl instanceof Element) || !lobbyPlayerGroupsActionMenuEl.isConnected) return;
positionLobbyGroupsMenuLikeBonkDefault(lobbyPlayerGroupsActionMenuEl, null);
if (lobbyPlayerGroupsPickerMenuEl instanceof Element && lobbyPlayerGroupsPickerMenuEl.isConnected) {
positionLobbyGroupsMenuLikeBonkDefault(lobbyPlayerGroupsPickerMenuEl, lobbyPlayerGroupsActionMenuEl);
}
};
window.addEventListener('resize', sync, { passive: true });
}
let recolorLobbyAccountInfoCache = null;
let recolorLobbyAccountInfoCacheAt = 0;
function getLobbyAccountInfoCached(maxAgeMs = 800) {
const now = Date.now();
if (recolorLobbyAccountInfoCache && (now - recolorLobbyAccountInfoCacheAt) <= Math.max(0, maxAgeMs)) {
return recolorLobbyAccountInfoCache;
}
const accountSet = new Set();
const guestSet = new Set();
const levelMap = new Map();
const rows = document.querySelectorAll(
'#newbonklobby_playerbox .newbonklobby_playerentry, #newbonklobby_specbox .newbonklobby_playerentry'
);
rows.forEach((row) => {
const nameEl = row.querySelector('.newbonklobby_playerentry_name');
const lvlEl = row.querySelector('.newbonklobby_playerentry_level');
const nameText = String((nameEl && nameEl.textContent) || '').trim();
const lvlText = String((lvlEl && lvlEl.textContent) || '').trim().toLowerCase();
const key = normalizeName(nameText);
if (!key) return;
const isGuest = lvlText === 'guest' || /\bguest\b/.test(lvlText);
if (isGuest) {
guestSet.add(key);
levelMap.set(key, 'guest');
} else if (lvlText) {
accountSet.add(key);
levelMap.set(key, 'level');
} else if (!levelMap.has(key)) {
levelMap.set(key, 'unknown');
}
});
recolorLobbyAccountInfoCache = { accountSet, guestSet, levelMap };
recolorLobbyAccountInfoCacheAt = now;
return recolorLobbyAccountInfoCache;
}
function getLobbyPlayerMenuOwnerMeta() {
const nameNorm = lobbyPlayerMenuOwnerNameNorm;
if (!nameNorm) return null;
const rows = document.querySelectorAll('.newbonklobby_playerentry');
for (const row of rows) {
const nameEl = row.querySelector('.newbonklobby_playerentry_name');
const displayName = String((nameEl && nameEl.textContent) || '').trim();
if (!displayName) continue;
if (normalizeName(displayName) !== nameNorm) continue;
const lvlEl = row.querySelector('.newbonklobby_playerentry_level');
const lvlText = String((lvlEl && lvlEl.textContent) || '').trim().toLowerCase();
const isGuest = lvlText === 'guest' || /\bguest\b/.test(lvlText);
return {
playerName: displayName,
playerNameNorm: nameNorm,
isGuest,
};
}
const info = getLobbyAccountInfoCached(500);
const lvlState = String((info && info.levelMap && info.levelMap.get(nameNorm)) || '');
if (!lvlState) return null;
return {
playerName: lobbyPlayerMenuOwnerNameNorm,
playerNameNorm: nameNorm,
isGuest: lvlState === 'guest',
};
}
function escapeLobbyMenuHtml(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderLobbyPlayerGroupsTargetMenu(anchorEl, playerName, mode, fromGroupId = '', memberType = 'any') {
if (!(anchorEl instanceof Element)) return;
const groups = Array.isArray(colorGroups) ? colorGroups.slice() : [];
const fromGroup = fromGroupId ? (colorGroups.find((g) => g.id === fromGroupId) || null) : findGroupByPlayerName(playerName, memberType);
const targets = mode === 'move'
? (fromGroup ? groups.filter((g) => g.id !== fromGroup.id) : groups)
: groups;
if (!targets.length) return;
if (lobbyPlayerGroupsTargetMenuEl && lobbyPlayerGroupsTargetMenuEl.parentNode) {
lobbyPlayerGroupsTargetMenuEl.parentNode.removeChild(lobbyPlayerGroupsTargetMenuEl);
}
ensureLobbyPlayerGroupsMenuStyles();
const menuEl = document.createElement('div');
menuEl.className = 'newbonklobby_playerentry_menu_submenu tbc_player_groups_target_menu';
menuEl.setAttribute('data-tbc-player-groups-target-menu', '1');
const tint = getPlayerListMenuGroupColor();
setPlayerListMenuBackground(menuEl, tint);
menuEl.innerHTML = targets
.map((g) => (
`<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow" data-tbc-player-groups-target="${escapeLobbyMenuHtml(g.id)}" title="${escapeLobbyMenuHtml(g.name)}" aria-label="${escapeLobbyMenuHtml(g.name)}" style="--tbc-group-color:${escapeLobbyMenuHtml(g.color || '#7f6151')};background:var(--tbc-group-color);color:transparent;text-shadow:none;"> </div>`
))
.join('');
menuEl.addEventListener('mousedown', (e) => {
e.stopPropagation();
}, true);
menuEl.addEventListener('click', (e) => {
e.stopPropagation();
}, true);
menuEl.addEventListener('click', (e) => {
const btn = e.target && e.target.closest ? e.target.closest('[data-tbc-player-groups-target]') : null;
if (!btn) return;
const groupId = String(btn.getAttribute('data-tbc-player-groups-target') || '').trim();
if (!groupId) return;
let res = null;
if (mode === 'add') {
res = addPlayerToGroup(groupId, playerName, { allowGuest: true, memberType });
} else if (fromGroup) {
res = movePlayerToGroup(fromGroup.id, groupId, playerName, memberType);
} else {
res = addPlayerToGroup(groupId, playerName, { allowGuest: true, memberType });
}
if (!res || !res.ok) {
addLocalChatStatus(`[TBC] ${res && res.error ? res.error : 'Could not update groups.'}`, 'rgb(181, 48, 48)');
return;
}
queueGroupsPanelActionAutoSync();
closeLobbyPlayerGroupsMenus();
scheduleLobbyScan();
});
document.body.appendChild(menuEl);
const ar = anchorEl.getBoundingClientRect();
positionLobbyFloatingMenu(menuEl, ar.right + 2, ar.top);
lobbyPlayerGroupsTargetMenuEl = menuEl;
}
function renderLobbyPlayerGroupsActionMenu(anchorEl, playerName, playerIsGuest = false) {
if (!(anchorEl instanceof Element)) return;
ensureLobbyPlayerGroupsPositionSync();
const defaultMemberType = playerIsGuest ? 'guest' : 'account';
if (lobbyPlayerGroupsPickerMenuEl && lobbyPlayerGroupsPickerMenuEl.parentNode) {
lobbyPlayerGroupsPickerMenuEl.parentNode.removeChild(lobbyPlayerGroupsPickerMenuEl);
}
lobbyPlayerGroupsPickerMenuEl = null;
setLobbyGroupsActionMenuMasked(false);
if (lobbyPlayerGroupsActionMenuEl && lobbyPlayerGroupsActionMenuEl.parentNode) {
lobbyPlayerGroupsActionMenuEl.parentNode.removeChild(lobbyPlayerGroupsActionMenuEl);
}
if (lobbyPlayerGroupsTargetMenuEl && lobbyPlayerGroupsTargetMenuEl.parentNode) {
lobbyPlayerGroupsTargetMenuEl.parentNode.removeChild(lobbyPlayerGroupsTargetMenuEl);
}
lobbyPlayerGroupsTargetMenuEl = null;
ensureLobbyPlayerGroupsMenuStyles();
const menuEl = document.createElement('div');
menuEl.className = 'newbonklobby_playerentry_menu_submenu tbc_player_groups_action_menu';
menuEl.setAttribute('data-tbc-player-groups-action-menu', '1');
const tint = getPlayerListMenuGroupColor();
setPlayerListMenuBackground(menuEl, tint);
const resolveLivePlayerName = () => {
const liveMeta = getLobbyPlayerMenuOwnerMeta();
return String((liveMeta && liveMeta.playerName) || playerName || '').trim();
};
const removeLivePlayerFromGroups = () => {
const liveMeta = getLobbyPlayerMenuOwnerMeta();
const livePlayerName = resolveLivePlayerName();
const liveMemberType = liveMeta && typeof liveMeta.isGuest === 'boolean'
? (liveMeta.isGuest ? 'guest' : 'account')
: defaultMemberType;
let removed = removePlayerFromAllGroups(livePlayerName, liveMemberType);
if ((!removed || !removed.ok) && liveMeta && liveMeta.playerNameNorm) {
removed = removePlayerFromAllGroups(liveMeta.playerNameNorm, liveMemberType);
}
if ((!removed || !removed.ok) && playerName) {
removed = removePlayerFromAllGroups(playerName, defaultMemberType);
}
if (removed && removed.ok) {
if (typeof queueGroupsPanelActionAutoSync === 'function') {
queueGroupsPanelActionAutoSync();
} else if (typeof broadcastSharedGroupsFromHost === 'function' && isSelfLobbyHost()) {
broadcastSharedGroupsFromHost(true, { silent: true, skipNoop: true });
}
}
scheduleLobbyScan();
closeLobbyPlayerMenusAll();
};
const triggerGroupsAutoSyncSafe = () => {
if (typeof queueGroupsPanelActionAutoSync === 'function') {
queueGroupsPanelActionAutoSync();
return;
}
if (typeof broadcastSharedGroupsFromHost === 'function' && isSelfLobbyHost()) {
broadcastSharedGroupsFromHost(true, { silent: true, skipNoop: true });
}
};
const renderActionsView = () => {
const groups = Array.isArray(colorGroups) ? colorGroups.slice() : [];
const currentGroup = findGroupByPlayerName(playerName, defaultMemberType);
const inAnyGroup = isPlayerInAnyGroup(playerName, defaultMemberType);
const addDisabled = inAnyGroup || groups.length < 1;
const moveTargets = currentGroup ? groups.filter((g) => g.id !== currentGroup.id) : [];
const moveDisabled = !inAnyGroup || moveTargets.length < 1;
const removeDisabled = !inAnyGroup;
menuEl.innerHTML = `
<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow${addDisabled ? ' brownButtonDisabled' : ''}" data-tbc-player-groups-action="add">Add</div>
<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow${moveDisabled ? ' brownButtonDisabled' : ''}" data-tbc-player-groups-action="move">Move</div>
<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow${removeDisabled ? ' brownButtonDisabled' : ''}" data-tbc-player-groups-action="remove">Remove</div>
`;
const addBtn = menuEl.querySelector('[data-tbc-player-groups-action="add"]');
const moveBtn = menuEl.querySelector('[data-tbc-player-groups-action="move"]');
const removeBtn = menuEl.querySelector('[data-tbc-player-groups-action="remove"]');
if (addBtn) {
addBtn.addEventListener('mouseup', (e) => {
e.preventDefault();
e.stopPropagation();
if (addBtn.classList.contains('brownButtonDisabled')) return;
openTargetsPickerPanel('add');
}, true);
}
if (moveBtn) {
moveBtn.addEventListener('mouseup', (e) => {
e.preventDefault();
e.stopPropagation();
if (moveBtn.classList.contains('brownButtonDisabled')) return;
openTargetsPickerPanel('move');
}, true);
}
if (removeBtn) {
removeBtn.addEventListener('mouseup', (e) => {
e.preventDefault();
e.stopPropagation();
wipeLobbyPlayerEntryMenus();
if (removeBtn.classList.contains('brownButtonDisabled')) {
closeLobbyPlayerMenusAll();
return;
}
removeBtn.classList.add('brownButtonDisabled');
if (moveBtn) moveBtn.classList.add('brownButtonDisabled');
removeLivePlayerFromGroups();
}, true);
}
};
const refreshActionsView = () => {
if (lobbyPlayerGroupsPickerMenuEl && lobbyPlayerGroupsPickerMenuEl.parentNode) {
lobbyPlayerGroupsPickerMenuEl.parentNode.removeChild(lobbyPlayerGroupsPickerMenuEl);
}
lobbyPlayerGroupsPickerMenuEl = null;
setLobbyGroupsActionMenuMasked(false);
renderActionsView();
requestAnimationFrame(() => {
if (lobbyPlayerGroupsActionMenuEl === menuEl) renderActionsView();
});
};
const openTargetsPickerPanel = (mode) => {
const latestGroups = Array.isArray(colorGroups) ? colorGroups.slice() : [];
const latestCurrentGroup = findGroupByPlayerName(playerName, defaultMemberType);
const sourceGroupIdAtOpen = latestCurrentGroup ? String(latestCurrentGroup.id || '') : '';
const targets = mode === 'move'
? (latestCurrentGroup ? latestGroups.filter((g) => g.id !== latestCurrentGroup.id) : [])
: latestGroups;
if (!targets.length) {
refreshActionsView();
return;
}
if (lobbyPlayerGroupsPickerMenuEl && lobbyPlayerGroupsPickerMenuEl.parentNode) {
lobbyPlayerGroupsPickerMenuEl.parentNode.removeChild(lobbyPlayerGroupsPickerMenuEl);
}
lobbyPlayerGroupsPickerMenuEl = null;
const pickerEl = document.createElement('div');
pickerEl.id = 'tbc_groups_subsubpanel';
pickerEl.className = 'newbonklobby_playerentry_menu_submenu tbc_player_groups_target_menu';
pickerEl.setAttribute('data-tbc-player-groups-target-menu', '1');
pickerEl.style.display = 'block';
pickerEl.style.visibility = 'visible';
pickerEl.style.zIndex = '2147483000';
const pickerTint = getPlayerListMenuGroupColor();
setPlayerListMenuBackground(pickerEl, pickerTint);
const listMaxPx = 8 * 29;
const listStyle = targets.length > 24
? `max-height:${listMaxPx}px;overflow-y:auto;overflow-x:hidden;`
: 'overflow:visible;';
pickerEl.innerHTML = `
<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow" data-tbc-player-groups-action="back">Back</div>
<div data-tbc-player-groups-move-list="1" style="${listStyle}">
${targets.map((g) => (
`<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow" data-tbc-player-groups-target="${escapeLobbyMenuHtml(g.id)}" data-tbc-player-groups-target-mode="${escapeLobbyMenuHtml(mode)}" title="${escapeLobbyMenuHtml(g.name)}" aria-label="${escapeLobbyMenuHtml(g.name)}" style="--tbc-group-color:${escapeLobbyMenuHtml(g.color || '#7f6151')};background:var(--tbc-group-color);color:transparent;text-shadow:none;"> </div>`
)).join('')}
<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow" data-tbc-player-groups-target-create="${escapeLobbyMenuHtml(mode)}" title="Create new group" aria-label="Create new group" style="background:rgba(127,97,81,0.45);color:#fff;font-weight:700;text-shadow:none;">+</div>
</div>
`;
pickerEl.addEventListener('mousedown', (e) => {
e.stopPropagation();
}, true);
pickerEl.addEventListener('click', (e) => {
e.stopPropagation();
}, true);
const backBtn = pickerEl.querySelector('[data-tbc-player-groups-action="back"]');
if (backBtn) {
backBtn.addEventListener('mouseup', (e) => {
e.preventDefault();
e.stopPropagation();
refreshActionsView();
}, true);
}
const applyToTargetGroup = (targetGroupId, targetMode) => {
if (!targetGroupId || !targetMode) return;
const liveMeta = getLobbyPlayerMenuOwnerMeta();
const liveMemberType = liveMeta && typeof liveMeta.isGuest === 'boolean'
? (liveMeta.isGuest ? 'guest' : 'account')
: defaultMemberType;
let res = null;
if (targetMode === 'add') {
const liveName = resolveLivePlayerName();
res = addPlayerToGroup(targetGroupId, liveName || playerName, { allowGuest: true, memberType: liveMemberType });
} else if (targetMode === 'move') {
const liveName = resolveLivePlayerName();
const effectiveName = liveName || playerName;
const latestCurrentGroup = findGroupByPlayerName(effectiveName, liveMemberType);
let fromGroupId = latestCurrentGroup ? String(latestCurrentGroup.id || '') : '';
if (!fromGroupId && sourceGroupIdAtOpen) {
const stillInSource = findPlayerInGroup(sourceGroupIdAtOpen, effectiveName, liveMemberType);
if (stillInSource) fromGroupId = sourceGroupIdAtOpen;
}
if (!fromGroupId) {
refreshActionsView();
return;
}
res = movePlayerToGroup(fromGroupId, targetGroupId, effectiveName, liveMemberType);
}
if (!res || !res.ok) {
addLocalChatStatus(`[TBC] ${res && res.error ? res.error : 'Could not update groups.'}`, 'rgb(181, 48, 48)');
return;
}
triggerGroupsAutoSyncSafe();
scheduleLobbyScan();
closeLobbyPlayerMenusAll();
};
pickerEl.querySelectorAll('[data-tbc-player-groups-target]').forEach((targetBtn) => {
targetBtn.addEventListener('mouseup', (e) => {
e.preventDefault();
e.stopPropagation();
const targetGroupId = String(targetBtn.getAttribute('data-tbc-player-groups-target') || '').trim();
const targetMode = String(targetBtn.getAttribute('data-tbc-player-groups-target-mode') || '').trim();
applyToTargetGroup(targetGroupId, targetMode);
}, true);
});
const createBtn = pickerEl.querySelector('[data-tbc-player-groups-target-create]');
if (createBtn) {
createBtn.addEventListener('mouseup', (e) => {
e.preventDefault();
e.stopPropagation();
const targetMode = String(createBtn.getAttribute('data-tbc-player-groups-target-create') || '').trim();
if (!targetMode) return;
const defaultColor = (Array.isArray(COLOR_PRESETS) && COLOR_PRESETS[0] && COLOR_PRESETS[0].color)
? String(COLOR_PRESETS[0].color)
: getRandomPresetColor();
let selectedColor = defaultColor;
const createPresetsHtml = (Array.isArray(COLOR_PRESETS) ? COLOR_PRESETS : [])
.map((p) => {
const clr = String((p && p.color) || '').trim() || '#7f6151';
const selected = clr.toLowerCase() === selectedColor.toLowerCase();
const label = String((p && p.label) || clr);
return `<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow" data-tbc-player-groups-create-preset="${escapeLobbyMenuHtml(clr)}" data-selected="${selected ? '1' : '0'}" title="${escapeLobbyMenuHtml(label)}" aria-label="${escapeLobbyMenuHtml(label)}" style="--tbc-group-color:${escapeLobbyMenuHtml(clr)};"> </div>`;
})
.join('');
pickerEl.innerHTML = `
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;">
<input type="text" data-tbc-player-groups-create-name placeholder="Group name" value="New Group" style="flex:1;min-width:0;height:24px;padding:0 6px;border:1px solid rgba(58,41,33,0.8);border-radius:6px;outline:none;background:rgba(255,255,255,0.95);color:#2e2019;box-sizing:border-box;">
</div>
<div style="display:flex;gap:4px;align-items:center;margin-bottom:4px;">
<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow" data-tbc-player-groups-action="create-confirm" style="width:calc(50% - 2px);min-width:0;">Create</div>
<div class="newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow" data-tbc-player-groups-action="create-cancel" style="width:calc(50% - 2px);min-width:0;">Cancel</div>
</div>
<div data-tbc-player-groups-create-presets="1" style="display:flex;flex-wrap:wrap;gap:4px;align-content:flex-start;">
${createPresetsHtml}
</div>
`;
const cancelCreateBtn = pickerEl.querySelector('[data-tbc-player-groups-action="create-cancel"]');
if (cancelCreateBtn) {
cancelCreateBtn.addEventListener('mouseup', (ev) => {
ev.preventDefault();
ev.stopPropagation();
openTargetsPickerPanel(targetMode);
}, true);
}
const presetBtns = pickerEl.querySelectorAll('[data-tbc-player-groups-create-preset]');
presetBtns.forEach((presetBtn) => {
presetBtn.addEventListener('mouseup', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const clr = String(presetBtn.getAttribute('data-tbc-player-groups-create-preset') || '').trim();
if (!clr) return;
selectedColor = clr;
presetBtns.forEach((el) => el.setAttribute('data-selected', el === presetBtn ? '1' : '0'));
}, true);
});
const commitCreate = () => {
const inputEl = pickerEl.querySelector('[data-tbc-player-groups-create-name]');
const typed = String((inputEl && inputEl.value) || '').trim();
const baseName = typed || 'New Group';
const existing = new Set(
(Array.isArray(colorGroups) ? colorGroups : [])
.map((g) => normalizeName(g && g.name))
.filter(Boolean)
);
let name = baseName;
if (existing.has(normalizeName(name))) {
let idx = 2;
while (existing.has(normalizeName(`${baseName} ${idx}`))) idx += 1;
name = `${baseName} ${idx}`;
}
const prevCount = Array.isArray(colorGroups) ? colorGroups.length : 0;
addGroup(name, selectedColor);
const createdGroup = Array.isArray(colorGroups) && colorGroups.length > prevCount ? colorGroups[colorGroups.length - 1] : null;
const createdId = String((createdGroup && createdGroup.id) || '').trim();
if (!createdId) {
addLocalChatStatus('[TBC] Could not create a new group.', 'rgb(181, 48, 48)');
return;
}
applyToTargetGroup(createdId, targetMode);
};
const createConfirmBtn = pickerEl.querySelector('[data-tbc-player-groups-action="create-confirm"]');
if (createConfirmBtn) {
createConfirmBtn.addEventListener('mouseup', (ev) => {
ev.preventDefault();
ev.stopPropagation();
commitCreate();
}, true);
}
const inputEl = pickerEl.querySelector('[data-tbc-player-groups-create-name]');
if (inputEl) {
inputEl.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter') return;
ev.preventDefault();
ev.stopPropagation();
commitCreate();
}, true);
inputEl.focus();
inputEl.select();
}
}, true);
}
document.body.appendChild(pickerEl);
positionLobbyGroupsMenuLikeBonkDefault(pickerEl, menuEl);
lobbyPlayerGroupsPickerMenuEl = pickerEl;
setLobbyGroupsActionMenuMasked(true);
};
renderActionsView();
menuEl.addEventListener('mousedown', (e) => {
e.stopPropagation();
}, true);
menuEl.addEventListener('click', (e) => {
e.stopPropagation();
}, true);
document.body.appendChild(menuEl);
positionLobbyGroupsMenuLikeBonkDefault(menuEl, anchorEl);
lobbyPlayerGroupsActionMenuEl = menuEl;
setLobbyMainPlayerMenuMasked(true);
}
function injectLobbyPlayerGroupsButton(menuEl) {
if (!(menuEl instanceof Element)) return;
if (!menuEl.classList || !menuEl.classList.contains('newbonklobby_playerentry_menu')) return;
if (menuEl.querySelector('[data-tbc-player-groups-button]')) return;
const meta = getLobbyPlayerMenuOwnerMeta();
if (!meta || !meta.playerName) return;
const btn = document.createElement('div');
btn.className = 'newbonklobby_playerentry_menu_button brownButton brownButton_classic buttonShadow';
btn.textContent = 'Groups';
btn.setAttribute('data-tbc-player-groups-button', '1');
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
}, true);
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (
lobbyPlayerGroupsActionMenuEl &&
lobbyPlayerGroupsActionMenuEl.parentNode &&
lobbyPlayerGroupsActionMenuEl.getAttribute('data-tbc-player-groups-owner') === meta.playerNameNorm
) {
closeLobbyPlayerGroupsMenus();
return;
}
renderLobbyPlayerGroupsActionMenu(btn, meta.playerName, !!meta.isGuest);
if (lobbyPlayerGroupsActionMenuEl) {
lobbyPlayerGroupsActionMenuEl.setAttribute('data-tbc-player-groups-owner', meta.playerNameNorm);
}
});
menuEl.appendChild(btn);
}
function setLobbyBlacklistButtonVisual(btn, active = false, disabled = false) {
if (!(btn instanceof Element)) return;
if (disabled) btn.classList.add('brownButtonDisabled');
else btn.classList.remove('brownButtonDisabled');
if (active) btn.style.setProperty('background', '#B75545', 'important');
else btn.style.removeProperty('background');
}
function injectLobbyPlayerBlacklistButton(menuEl) {
if (!(menuEl instanceof Element)) return;
if (!menuEl.classList || !menuEl.classList.contains('newbonklobby_playerentry_menu')) return;
const meta = getLobbyPlayerMenuOwnerMeta();
if (!meta || !meta.playerName) return;
const readSelfNameNorm = () => {
const selfEl = document.getElementById('pretty_top_name');
return selfEl ? normalizeName(selfEl.textContent || '') : '';
};
const playerNorm = meta.playerNameNorm || normalizeName(meta.playerName);
const selfNorm = readSelfNameNorm();
const isSelfTarget = !!selfNorm && !!playerNorm && selfNorm === playerNorm;
const muteBtn = Array.from(menuEl.querySelectorAll('.newbonklobby_playerentry_menu_button'))
.find((el) => /^\s*(mute|unmute)\s*$/i.test(String(el.textContent || '').trim()));
if (isSelfTarget) {
if (muteBtn instanceof Element) muteBtn.style.display = 'none';
const existingSelfBtn = menuEl.querySelector('[data-tbc-player-blacklist-button]');
if (existingSelfBtn && existingSelfBtn.parentNode) existingSelfBtn.parentNode.removeChild(existingSelfBtn);
return;
}
if (menuEl.querySelector('[data-tbc-player-blacklist-button]')) return;
const baseMuteWidth =
muteBtn instanceof Element
? Math.round((muteBtn.getBoundingClientRect && muteBtn.getBoundingClientRect().width) || muteBtn.offsetWidth || 130)
: 130;
if (muteBtn instanceof Element) {
muteBtn.style.removeProperty('display');
muteBtn.style.display = 'inline-block';
muteBtn.style.verticalAlign = 'top';
muteBtn.style.marginRight = '4px';
}
const btn = document.createElement('div');
btn.className = 'newbonklobby_playerentry_menu_button brownButton buttonShadow brownButton_classic';
btn.textContent = 'Blacklist';
btn.title = 'Blacklist';
btn.setAttribute('aria-label', 'Blacklist');
btn.setAttribute('data-tbc-player-blacklist-button', '1');
btn.style.display = 'inline-block';
btn.style.verticalAlign = 'top';
const applySplitButtonWidths = () => {
if (!(muteBtn instanceof Element)) return;
menuEl.style.removeProperty('min-width');
const menuWidth = Math.max(96, baseMuteWidth || 130);
const gap = 4;
let rightWidth = meta.isGuest ? 24 : 56;
const minLeft = 54;
if ((menuWidth - gap - rightWidth) < minLeft) {
rightWidth = Math.max(24, menuWidth - gap - minLeft);
}
const leftWidth = Math.max(44, menuWidth - gap - rightWidth);
muteBtn.style.width = `${leftWidth}px`;
btn.style.width = `${rightWidth}px`;
};
const fitBlacklistLabel = () => {
if (!(btn instanceof Element)) return;
if (meta.isGuest) {
btn.textContent = 'B';
return;
}
btn.textContent = 'Blacklist';
const tooLong = btn.scrollWidth > btn.clientWidth;
if (tooLong) btn.textContent = 'B';
};
const refreshState = () => {
const selfNorm = readSelfNameNorm();
const disabledTarget = !!meta.isGuest || (!!selfNorm && !!playerNorm && selfNorm === playerNorm);
const active = !disabledTarget &&
typeof window.tbcIsNameBlacklisted === 'function' &&
!!window.tbcIsNameBlacklisted(meta.playerName);
setLobbyBlacklistButtonVisual(btn, active, disabledTarget);
};
refreshState();
btn.addEventListener('mouseup', (e) => {
e.preventDefault();
e.stopPropagation();
const selfNorm = readSelfNameNorm();
const disabledTarget = !!meta.isGuest || (!!selfNorm && !!playerNorm && selfNorm === playerNorm);
if (disabledTarget) return;
if (typeof window.tbcToggleBlacklistName === 'function') {
window.tbcToggleBlacklistName(meta.playerName);
}
refreshState();
}, true);
const groupsBtn = menuEl.querySelector('[data-tbc-player-groups-button]');
if (muteBtn instanceof Element) muteBtn.insertAdjacentElement('afterend', btn);
else if (groupsBtn && groupsBtn.parentNode === menuEl) menuEl.insertBefore(btn, groupsBtn);
else menuEl.appendChild(btn);
applySplitButtonWidths();
fitBlacklistLabel();
requestAnimationFrame(() => {
applySplitButtonWidths();
fitBlacklistLabel();
});
}
function installLobbyPlayerMenuOwnerTracking() {
if (lobbyPlayerMenuOwnerTrackingInstalled) return;
lobbyPlayerMenuOwnerTrackingInstalled = true;
const applyMenuTintNow = () => {
const color = getPlayerListMenuGroupColor();
document.querySelectorAll('.newbonklobby_playerentry_menu, .newbonklobby_playerentry_menu_submenu').forEach((menuEl) => {
setPlayerListMenuBackground(menuEl, color);
});
};
const promotePendingMenuOwner = () => {
if (!lobbyPlayerMenuPendingOwnerNameNorm) return false;
lobbyPlayerMenuOwnerNameNorm = lobbyPlayerMenuPendingOwnerNameNorm;
lobbyPlayerMenuPendingOwnerNameNorm = '';
return true;
};
document.addEventListener(
'mousedown',
(e) => {
const target = e && e.target instanceof Element ? e.target : null;
if (!target) return;
const row = target.closest('.newbonklobby_playerentry');
if (row) {
closeLobbyPlayerGroupsMenus();
const nameEl = row.querySelector('.newbonklobby_playerentry_name');
const nm = normalizeName(nameEl ? nameEl.textContent : '');
if (nm) lobbyPlayerMenuPendingOwnerNameNorm = nm;
else {
lobbyPlayerMenuPendingOwnerNameNorm = '';
lobbyPlayerMenuOwnerNameNorm = '';
}
requestAnimationFrame(() => {
scheduleLobbyScan();
});
return;
}
const inMenu = !!target.closest('.newbonklobby_playerentry_menu, .newbonklobby_playerentry_menu_submenu, [data-tbc-player-groups-button], [data-tbc-player-groups-action-menu], [data-tbc-player-groups-target-menu]');
if (!inMenu && (lobbyPlayerMenuOwnerNameNorm || lobbyPlayerMenuPendingOwnerNameNorm)) {
lobbyPlayerMenuPendingOwnerNameNorm = '';
closeLobbyPlayerGroupsMenus();
}
},
true
);
document.addEventListener(
'click',
(e) => {
const target = e && e.target instanceof Element ? e.target : null;
if (!target) return;
const nativeSubBtn = target.closest('.newbonklobby_playerentry_menu_submenu .newbonklobby_playerentry_menu_button');
if (
!nativeSubBtn ||
nativeSubBtn.classList.contains('brownButtonDisabled') ||
nativeSubBtn.closest('[data-tbc-player-groups-action-menu]') ||
nativeSubBtn.closest('[data-tbc-player-groups-target-menu]')
) {
return;
}
setTimeout(() => {
document.querySelectorAll('.newbonklobby_playerentry_menu_submenu').forEach((el) => {
if (
el instanceof Element &&
!el.hasAttribute('data-tbc-player-groups-action-menu') &&
!el.hasAttribute('data-tbc-player-groups-target-menu')
) {
if (el.parentNode) el.parentNode.removeChild(el);
}
});
}, 0);
},
true
);
waitForElement('newbonklobby', () => {
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList' && m.removedNodes && m.removedNodes.length) {
let removedAnyPlayerMenu = false;
for (const node of m.removedNodes) {
if (!(node instanceof Element)) continue;
if (
node.classList &&
(
node.classList.contains('newbonklobby_playerentry_menu') ||
node.classList.contains('newbonklobby_playerentry_menu_submenu')
)
) {
removedAnyPlayerMenu = true;
break;
}
if (node.querySelector && node.querySelector('.newbonklobby_playerentry_menu, .newbonklobby_playerentry_menu_submenu')) {
removedAnyPlayerMenu = true;
break;
}
}
if (removedAnyPlayerMenu && !document.querySelector('.newbonklobby_playerentry_menu, .newbonklobby_playerentry_menu_submenu')) {
lobbyPlayerMenuOwnerNameNorm = '';
lobbyPlayerMenuPendingOwnerNameNorm = '';
closeLobbyPlayerGroupsMenus();
}
}
if (
m.type === 'attributes' &&
m.target instanceof Element &&
m.target.classList &&
(
m.target.classList.contains('newbonklobby_playerentry_menu') ||
m.target.classList.contains('newbonklobby_playerentry_menu_submenu')
)
) {
if (promotePendingMenuOwner()) applyMenuTintNow();
if (m.target.classList.contains('newbonklobby_playerentry_menu')) {
injectLobbyPlayerGroupsButton(m.target);
injectLobbyPlayerBlacklistButton(m.target);
}
}
for (const node of m.addedNodes) {
if (!(node instanceof Element)) continue;
if (
node.classList &&
(
node.classList.contains('newbonklobby_playerentry_menu') ||
node.classList.contains('newbonklobby_playerentry_menu_submenu')
)
) {
promotePendingMenuOwner();
setPlayerListMenuBackground(node, getPlayerListMenuGroupColor());
injectLobbyPlayerGroupsButton(node);
injectLobbyPlayerBlacklistButton(node);
}
node.querySelectorAll('.newbonklobby_playerentry_menu, .newbonklobby_playerentry_menu_submenu').forEach((menuEl) => {
promotePendingMenuOwner();
setPlayerListMenuBackground(menuEl, getPlayerListMenuGroupColor());
injectLobbyPlayerGroupsButton(menuEl);
injectLobbyPlayerBlacklistButton(menuEl);
});
}
}
});
obs.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
});
}
function setElementNameColor(el, color) {
if (!el) return;
const desiredColor = color || '';
if (desiredColor) {
if (el.style.color !== desiredColor) el.style.setProperty('color', desiredColor, 'important');
} else {
if (el.style.color) el.style.removeProperty('color');
}
if (el.style.textShadow) el.style.removeProperty('text-shadow');
}
function setElementNameColorWithGrace(el, color, enabled = true, datasetKey = 'tbcNameNoColorSince', graceMs = 1200) {
if (!el) return;
if (!enabled) {
if (el.dataset && datasetKey) delete el.dataset[datasetKey];
setElementNameColor(el, null);
return;
}
const desiredColor = String(color || '').trim();
if (desiredColor) {
if (el.dataset && datasetKey) delete el.dataset[datasetKey];
setElementNameColor(el, desiredColor);
return;
}
const hasInlineColor = !!(el.style && String(el.style.color || '').trim());
if (hasInlineColor && el.dataset && datasetKey) {
const now = Date.now();
const missAt = parseInt(String(el.dataset[datasetKey] || '0'), 10) || 0;
if (!missAt) {
el.dataset[datasetKey] = String(now);
return;
}
if ((now - missAt) < Math.max(0, graceMs)) return;
}
if (el.dataset && datasetKey) delete el.dataset[datasetKey];
setElementNameColor(el, null);
}
function ensurePlayerListGroupBgStyles() {
if ($('tbc_playerlist_group_bg_css')) return;
const style = document.createElement('style');
style.id = 'tbc_playerlist_group_bg_css';
style.textContent = `
.newbonklobby_playerentry.tbc_group_bg_row {
transition: none !important;
}
.newbonklobby_playerentry.tbc_group_bg_row:hover {
background-color: var(--tbc-player-bg-hover, transparent) !important;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.22);
}
`;
document.head.appendChild(style);
}
function setPlayerListEntryBackground(entry, color) {
if (!entry) return;
ensurePlayerListGroupBgStyles();
if (color) {
const merged = mergeColorWithLobbyBase(color, 0.25);
if (!entry.classList.contains('tbc_group_bg_row')) entry.classList.add('tbc_group_bg_row');
if (entry.style.getPropertyValue('--tbc-player-bg-base') !== merged) {
entry.style.setProperty('--tbc-player-bg-base', merged);
}
if (entry.style.getPropertyValue('--tbc-player-bg-hover') !== merged) {
entry.style.setProperty('--tbc-player-bg-hover', merged);
}
if (entry.style.getPropertyValue('background-color') !== 'var(--tbc-player-bg-base)') {
entry.style.setProperty('background-color', 'var(--tbc-player-bg-base)', 'important');
}
} else if (
entry.classList.contains('tbc_group_bg_row') ||
entry.style.getPropertyValue('background-color') ||
entry.style.getPropertyValue('--tbc-player-bg-base') ||
entry.style.getPropertyValue('--tbc-player-bg-hover')
) {
entry.classList.remove('tbc_group_bg_row');
entry.style.removeProperty('background-color');
entry.style.removeProperty('--tbc-player-bg-base');
entry.style.removeProperty('--tbc-player-bg-hover');
}
}
function setPlayerListMenuBackground(menuEl, color) {
if (!menuEl) return;
if (color) {
menuEl.style.setProperty('background-color', mergeColorWithLobbyBase(color, 0.25), 'important');
} else if (menuEl.style.backgroundColor) {
menuEl.style.removeProperty('background-color');
}
}
function mergeColorWithLobbyBase(inputColor, ratio = 0.1) {
const parseHexToRgb = (hexText) => {
let s = String(hexText || '').trim().toLowerCase();
if (!s) return null;
if (s.charAt(0) !== '#') s = `#${s}`;
if (/^#[0-9a-f]{3}$/.test(s)) {
const a = s.charAt(1), b = s.charAt(2), c = s.charAt(3);
s = `#${a}${a}${b}${b}${c}${c}`;
}
if (!/^#[0-9a-f]{6}$/.test(s)) return null;
return {
r: parseInt(s.slice(1, 3), 16),
g: parseInt(s.slice(3, 5), 16),
b: parseInt(s.slice(5, 7), 16),
};
};
const base = parseHexToRgb('#cfd8dc') || { r: 207, g: 216, b: 220 };
const raw = String(inputColor || '').trim();
let hex = null;
if (/^\s*rgb/i.test(raw)) {
const nums = raw.match(/-?\d+(\.\d+)?/g);
if (nums && nums.length >= 3) {
const r = Math.max(0, Math.min(255, Math.round(parseFloat(nums[0]) || 0)));
const g = Math.max(0, Math.min(255, Math.round(parseFloat(nums[1]) || 0)));
const b = Math.max(0, Math.min(255, Math.round(parseFloat(nums[2]) || 0)));
hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
} else if (/^\s*hsv/i.test(raw)) {
const nums = raw.match(/-?\d+(\.\d+)?/g);
if (nums && nums.length >= 3) {
let h = parseFloat(nums[0]);
let s = parseFloat(nums[1]);
let v = parseFloat(nums[2]);
if (Number.isFinite(h) && Number.isFinite(s) && Number.isFinite(v)) {
h = ((h % 360) + 360) % 360;
s = Math.max(0, Math.min(1, s > 1 ? (s / 100) : s));
v = Math.max(0, Math.min(1, v > 1 ? (v / 100) : v));
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let rp = 0, gp = 0, bp = 0;
if (h < 60) { rp = c; gp = x; bp = 0; }
else if (h < 120) { rp = x; gp = c; bp = 0; }
else if (h < 180) { rp = 0; gp = c; bp = x; }
else if (h < 240) { rp = 0; gp = x; bp = c; }
else if (h < 300) { rp = x; gp = 0; bp = c; }
else { rp = c; gp = 0; bp = x; }
const r = Math.max(0, Math.min(255, Math.round((rp + m) * 255)));
const g = Math.max(0, Math.min(255, Math.round((gp + m) * 255)));
const b = Math.max(0, Math.min(255, Math.round((bp + m) * 255)));
hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
}
} else {
hex = raw;
}
const tint = parseHexToRgb(hex) || base;
const r = Math.max(0, Math.min(1, Number(ratio) || 0));
const outR = Math.round((base.r * (1 - r)) + (tint.r * r));
const outG = Math.round((base.g * (1 - r)) + (tint.g * r));
const outB = Math.round((base.b * (1 - r)) + (tint.b * r));
return `rgb(${outR}, ${outG}, ${outB})`;
}
function getPlayerListMenuGroupColor() {
if (!recolorDisplaySettings.playerListBackboard) return null;
if (!lobbyPlayerMenuOwnerNameNorm) return null;
const meta = getLobbyPlayerMenuOwnerMeta();
const memberType = meta && typeof meta.isGuest === 'boolean' ? (meta.isGuest ? 'guest' : 'account') : 'any';
return resolveLobbyPlayerPersistentColor(lobbyPlayerMenuOwnerNameNorm, true, false, memberType);
}
function resolveLobbyPlayerPersistentColor(playerName, allowSticky = true, clearStickyWhenMissing = false, memberType = 'any') {
const nm = normalizeName(playerName);
if (!nm) return null;
const key = `${nm}|${getLookupTypeSuffix(memberType)}`;
const live = getDisplayColorForName(playerName, { memberType });
if (live) {
lobbyPersistentColorByName.set(key, live);
return live;
}
if (allowSticky && lobbyPersistentColorByName.has(key)) return lobbyPersistentColorByName.get(key) || null;
if (clearStickyWhenMissing) lobbyPersistentColorByName.delete(key);
return null;
}
function getLobbyPersistentColorVersion(nameNorm) {
if (!nameNorm) return 0;
return lobbyPersistentColorVersionByName.get(nameNorm) || 0;
}
function invalidateLobbyPersistentColorsForPlayers(players, clearSharedCache = false) {
const changed = [];
const seen = new Set();
const list = Array.isArray(players) ? players : [];
list.forEach((name) => {
const nm = normalizeName(name);
if (!nm || seen.has(nm)) return;
seen.add(nm);
changed.push(nm);
lobbyPersistentColorByName.delete(`${nm}|account`);
lobbyPersistentColorByName.delete(`${nm}|guest`);
lobbyPersistentColorByName.delete(`${nm}|any`);
lobbyPersistentColorVersionByName.set(nm, getLobbyPersistentColorVersion(nm) + 1);
});
colorCache.clear();
if (clearSharedCache) {
lastSharedColorSig = '';
sharedColorCache.clear();
}
return changed;
}
function refreshLobbyPlayerEntriesForNames(nameNorms, clearWhenNoColor = true) {
const targets = Array.isArray(nameNorms) ? nameNorms.filter(Boolean) : [];
if (!targets.length) return;
const want = new Set(targets);
document.querySelectorAll('.newbonklobby_playerentry').forEach((entry) => {
const nameEl = entry.querySelector('.newbonklobby_playerentry_name');
const nm = normalizeName(nameEl ? nameEl.textContent : '');
if (!nm || !want.has(nm)) return;
applyLobbyPlayerEntryColor(entry, clearWhenNoColor, false, true);
});
}
function applyLobbyPlayerEntryColor(entry, clearWhenNoColor = true, allowSticky = true, clearStickyWhenMissing = false) {
if (!entry || !(entry instanceof Element)) return;
const nameEl = entry.querySelector('.newbonklobby_playerentry_name');
const levelEl = entry.querySelector('.newbonklobby_playerentry_level');
if (!nameEl || !levelEl) return;
if (!recolorDisplaySettings.playerListNames && !recolorDisplaySettings.playerListBackboard) {
setElementNameColor(nameEl, null);
setPlayerListEntryBackground(entry, null);
updateLobbyPlayerGuestGroupBadge(nameEl, false);
return;
}
const levelText = (levelEl.textContent || '').trim().toLowerCase();
const isLevelled = /^level\b/.test(levelText) || /^lv\b/.test(levelText) || /^lvl\b/.test(levelText);
const memberType = /\bguest\b/.test(levelText) ? 'guest' : 'account';
const playerName = (nameEl.textContent || '').trim();
if (!playerName) {
if (clearWhenNoColor) {
setElementNameColor(nameEl, null);
setPlayerListEntryBackground(entry, null);
}
updateLobbyPlayerGuestGroupBadge(nameEl, false);
delete entry.dataset.tbcColorNameNorm;
delete entry.dataset.tbcColorMemberType;
delete entry.dataset.tbcColorEpoch;
delete entry.dataset.tbcColorVersion;
delete entry.dataset.tbcColorValue;
return;
}
const nameNorm = normalizeName(playerName);
const sourceEpoch = lobbyPersistentColorEpoch;
const sourceVersion = getLobbyPersistentColorVersion(nameNorm);
const cacheMemberKey = `${nameNorm}|${getLookupTypeSuffix(memberType)}`;
const cachedNameNorm = String(entry.dataset.tbcColorNameNorm || '');
const cachedEpoch = parseInt(String(entry.dataset.tbcColorEpoch || '0'), 10) || 0;
const cachedVersion = parseInt(String(entry.dataset.tbcColorVersion || '0'), 10) || 0;
const cachedType = String(entry.dataset.tbcColorMemberType || '');
const canReuseRowColor = allowSticky && cachedNameNorm === nameNorm && cachedType === cacheMemberKey && cachedEpoch === sourceEpoch && cachedVersion === sourceVersion;
let color = null;
if (canReuseRowColor) {
color = String(entry.dataset.tbcColorValue || '').trim() || null;
} else {
if (allowSticky && !clearStickyWhenMissing && lobbyPersistentColorByName.has(cacheMemberKey)) {
color = lobbyPersistentColorByName.get(cacheMemberKey) || null;
} else {
color = resolveLobbyPlayerPersistentColor(playerName, allowSticky, clearStickyWhenMissing, memberType);
}
entry.dataset.tbcColorNameNorm = nameNorm;
entry.dataset.tbcColorMemberType = cacheMemberKey;
entry.dataset.tbcColorEpoch = String(sourceEpoch);
entry.dataset.tbcColorVersion = String(sourceVersion);
if (color) entry.dataset.tbcColorValue = color;
else delete entry.dataset.tbcColorValue;
}
const immediateAuthoritativeRefresh = !allowSticky && !!clearStickyWhenMissing;
const nameGraceMs = immediateAuthoritativeRefresh ? 0 : 1200;
const backboardGraceMs = immediateAuthoritativeRefresh ? 0 : 1200;
if (recolorDisplaySettings.playerListNames && isLevelled) {
setElementNameColorWithGrace(nameEl, color, true, 'tbcPlayerListNameNoColorSince', nameGraceMs);
} else {
setElementNameColorWithGrace(nameEl, null, false, 'tbcPlayerListNameNoColorSince', 0);
}
if (recolorDisplaySettings.playerListBackboard) {
const hasExistingBg =
entry.classList.contains('tbc_group_bg_row') ||
!!entry.style.getPropertyValue('--tbc-player-bg-base') ||
!!entry.style.getPropertyValue('background-color');
if (color) {
delete entry.dataset.tbcNoColorSince;
setPlayerListEntryBackground(entry, color);
} else if (clearWhenNoColor) {
if (hasExistingBg && backboardGraceMs > 0) {
const now = Date.now();
const missAt = parseInt(String(entry.dataset.tbcNoColorSince || '0'), 10) || 0;
if (!missAt) {
entry.dataset.tbcNoColorSince = String(now);
} else if ((now - missAt) >= backboardGraceMs) {
delete entry.dataset.tbcNoColorSince;
setPlayerListEntryBackground(entry, null);
}
} else {
delete entry.dataset.tbcNoColorSince;
setPlayerListEntryBackground(entry, null);
}
} else {
delete entry.dataset.tbcNoColorSince;
}
} else {
delete entry.dataset.tbcNoColorSince;
setPlayerListEntryBackground(entry, null);
}
const isGuest = memberType === 'guest';
const showGuestGroupWarn = isGuest && (!!getDisplayColorForName(playerName, { memberType: 'guest' }) || isPlayerInAnyGroup(playerName, 'guest'));
updateLobbyPlayerGuestGroupBadge(nameEl, showGuestGroupWarn);
}
function updateLobbyPlayerGuestGroupBadge(nameEl, show) {
if (!(nameEl instanceof Element)) return;
let badge = nameEl.querySelector(':scope > .tbc_guest_warn_badge_playerlist');
if (!show) {
if (badge && badge.parentNode) badge.parentNode.removeChild(badge);
return;
}
if (!badge) {
badge = document.createElement('span');
badge.className = 'tbc_guest_warn_badge tbc_guest_warn_badge_playerlist';
badge.title = 'Temporary guest member. Removed when guest/room/host state changes.';
badge.setAttribute('aria-label', 'Temporary guest member');
nameEl.appendChild(badge);
}
}
function refreshLobbyPersistentColorsFromGroups() {
lobbyPersistentColorEpoch += 1;
lobbyPersistentColorByName.clear();
const rows = document.querySelectorAll('.newbonklobby_playerentry');
rows.forEach((row) => applyLobbyPlayerEntryColor(row, true, false, true));
const menuColor = getPlayerListMenuGroupColor();
const playerMenus = document.querySelectorAll('.newbonklobby_playerentry_menu, .newbonklobby_playerentry_menu_submenu');
playerMenus.forEach((menuEl) => setPlayerListMenuBackground(menuEl, menuColor));
}
function applyLobbyNameColors(includePlayerList = true) {
if (includePlayerList) {
const playerEntries = document.querySelectorAll('.newbonklobby_playerentry');
playerEntries.forEach((entry) => {
applyLobbyPlayerEntryColor(entry, false, true, false);
});
}
const menuColor = getPlayerListMenuGroupColor();
const playerMenus = document.querySelectorAll('.newbonklobby_playerentry_menu, .newbonklobby_playerentry_menu_submenu');
playerMenus.forEach((menuEl) => setPlayerListMenuBackground(menuEl, menuColor));
const lobbyChatNames = document.querySelectorAll(
'#newbonklobby_chat_content .newbonklobby_chat_msg_name'
);
lobbyChatNames.forEach((nameSpan) => {
if (!recolorDisplaySettings.lobbyChatNames) {
setElementNameColorWithGrace(nameSpan, null, false, 'tbcChatNameNoColorSince', 0);
return;
}
const raw = (nameSpan.textContent || '').trim();
const cleanName = extractChatName(raw);
const color = getDisplayColorForName(cleanName);
setElementNameColorWithGrace(nameSpan, color, true, 'tbcChatNameNoColorSince', 1400);
});
const inGameChatNames = document.querySelectorAll('#ingamechatcontent .ingamechatname');
inGameChatNames.forEach((nameSpan) => {
if (!recolorDisplaySettings.ingameChatNames) {
setElementNameColorWithGrace(nameSpan, null, false, 'tbcChatNameNoColorSince', 0);
return;
}
const raw = (nameSpan.textContent || '').trim();
const cleanName = extractChatName(raw);
const color = getDisplayColorForName(cleanName);
setElementNameColorWithGrace(nameSpan, color, true, 'tbcChatNameNoColorSince', 1400);
});
}
let winnerScanQueued = false;
let winnerGroupedMode = false;
let winnerSourceEntries = [];
let lastGroupedWinnerWinSig = '';
let groupedWinnerEndRetryTimer = null;
function resetWinnerBoardTransientState() {
winnerGroupedMode = false;
winnerSourceEntries = [];
lastGroupedWinnerWinSig = '';
if (groupedWinnerEndRetryTimer) {
clearTimeout(groupedWinnerEndRetryTimer);
groupedWinnerEndRetryTimer = null;
}
const left = document.getElementById('ingamewinner_scores_left');
if (left) {
delete left.dataset.tbcWinnerGrouped;
}
clearWinnerHeadlineColor();
if (typeof window.tbcInvalidatePointsPanel === 'function') {
try { window.tbcInvalidatePointsPanel(); } catch {}
}
const pointsVisible =
typeof window.tbcIsPointsPanelVisible === 'function' &&
!!window.tbcIsPointsPanelVisible();
if (pointsVisible && typeof window.tbcRenderPointsPanel === 'function') {
try { window.tbcRenderPointsPanel(); } catch {}
}
}
function resetPointsPanelScoreSnapshot() {
if (typeof window.tbcResetPointsPanelCache === 'function') {
try { window.tbcResetPointsPanelCache(); } catch {}
} else if (typeof window.tbcInvalidatePointsPanel === 'function') {
try { window.tbcInvalidatePointsPanel(); } catch {}
}
winnerSourceEntries = [];
winnerGroupedMode = false;
lastGroupedWinnerWinSig = '';
}
function scheduleWinnerScan() {
if (winnerScanQueued) return;
winnerScanQueued = true;
requestAnimationFrame(() => {
winnerScanQueued = false;
applyWinnerNameColors();
const pointsVisible =
typeof window.tbcIsPointsPanelVisible === 'function' &&
!!window.tbcIsPointsPanelVisible();
if (pointsVisible && typeof window.tbcRenderPointsPanel === 'function') {
try { window.tbcRenderPointsPanel(); } catch {}
}
});
}
function isTeamsOffForWinnerBoard() {
const t1 = String((($('newbonklobby_teams_middletext') || {}).textContent || '')).trim().toLowerCase();
const t2 = String((($('newbonklobby_teamsbutton') || {}).textContent || '')).trim().toLowerCase();
return /\boff\b/.test(t1) || /\bffa\b/.test(t1) || /\boff\b/.test(t2) || /\bffa\b/.test(t2);
}
function getWinnerRoundsToWinCap() {
const top = document.getElementById('ingamewinner_scores_top');
const topText = String((top && (top.textContent || top.innerText)) || '');
const topMatch = topText.match(/first\s*to\s*(\d+)\s*wins/i);
if (topMatch && topMatch[1]) {
const n = parseInt(topMatch[1], 10);
if (Number.isFinite(n) && n > 0) return n;
}
const roundsInput = $('newbonklobby_roundsinput');
const fallback = parseInt(String((roundsInput && roundsInput.value) || ''), 10);
if (Number.isFinite(fallback) && fallback > 0) return fallback;
return 0;
}
function getGroupedWinnerReachedCapEntry(entries) {
const cap = getWinnerRoundsToWinCap();
if (cap <= 0 || !Array.isArray(entries) || !entries.length) return null;
const hit = entries.find((e) => (Number(e && e.score) || 0) >= cap);
if (!hit) return null;
return {
name: String(hit.name || '').trim(),
color: String(hit.color || '').trim(),
score: Number(hit.score) || 0,
cap,
};
}
function applyGroupedWinnerHeadlineOverride(winEntry) {
if (!winEntry || !winEntry.name) return;
const top = document.getElementById('ingamewinner_top');
const bottom = document.getElementById('ingamewinner_bottom');
if (top) {
top.textContent = String(winEntry.name);
setElementNameColor(top, winEntry.color ? String(winEntry.color) : '');
}
if (bottom) bottom.textContent = 'WINS';
}
function clearWinnerHeadlineColor() {
const top = document.getElementById('ingamewinner_top');
if (!top) return;
setElementNameColor(top, '');
}
function getWinnerHeadlineName() {
const top = document.getElementById('ingamewinner_top');
if (!top) return '';
const raw = String((top.innerText || top.textContent) || '').replace(/\r/g, '');
if (!raw) return '';
const lines = raw
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
if (!lines.length) return '';
for (const line of lines) {
const stripped = line.replace(/\b(scores?|wins?)\b/ig, '').replace(/:\s*$/, '').trim();
if (stripped) return stripped;
}
return lines[0].replace(/:\s*$/, '').trim();
}
function applyWinnerHeadlineColor(resolveColor) {
const top = document.getElementById('ingamewinner_top');
if (!top) return;
const winnerName = getWinnerHeadlineName();
if (!winnerName || typeof resolveColor !== 'function') {
clearWinnerHeadlineColor();
return;
}
const color = resolveColor(winnerName);
setElementNameColor(top, color ? String(color) : '');
}
function rewriteLatestScoresStatusToWins(winEntry) {
if (!winEntry || !winEntry.name) return;
const carrierSelectors = [
'#ingamechatcontent .ingamechatstatus',
'#ingamechatcontent .ingamechatmessage',
'#ingamechatcontent .ingamechattext',
'#newbonklobby_chat_content .newbonklobby_chat_status',
];
const carriers = Array.from(document.querySelectorAll(carrierSelectors.join(', ')));
for (let i = carriers.length - 1; i >= 0; i -= 1) {
const el = carriers[i];
const text = String((el && (el.textContent || el.innerText)) || '').trim();
if (!text) continue;
if (/^\*\s+.+\s+wins!?$/i.test(text)) return;
if (!/^\*\s+.+\s+scores!?$/i.test(text)) continue;
el.textContent = `* ${String(winEntry.name)} wins!`;
return;
}
}
function tryClickElement(el) {
if (!(el instanceof Element)) return false;
try {
if (typeof PointerEvent !== 'undefined') {
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerType: 'mouse' }));
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerType: 'mouse' }));
}
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
if (typeof el.onclick === 'function') {
try { el.onclick(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); } catch {}
}
if (typeof el.click === 'function') el.click();
return true;
} catch {
return false;
}
}
function triggerPrettyTopExitHard() {
const topExit = document.getElementById('pretty_top_exit');
if (!(topExit instanceof Element)) return false;
if (tryClickElement(topExit)) return true;
try {
const parent = topExit.parentElement || document;
parent.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
} catch {}
return true;
}
function isVisibleActionElement(el) {
if (!(el instanceof Element)) return false;
const cs = window.getComputedStyle(el);
return (
cs.display !== 'none' &&
cs.visibility !== 'hidden' &&
cs.opacity !== '0' &&
el.getClientRects().length > 0
);
}
function isElementActuallyVisibleSafe(el) {
if (!el || !(el instanceof Element)) return false;
if (typeof isElementActuallyVisible === 'function') {
try { return !!isElementActuallyVisible(el); } catch {}
}
const cs = window.getComputedStyle(el);
return (
cs.display !== 'none' &&
cs.visibility !== 'hidden' &&
cs.opacity !== '0' &&
el.getClientRects().length > 0
);
}
function attemptHostEndGameToLobby() {
if (typeof isSelfLobbyHost === 'function' && !isSelfLobbyHost()) return false;
const renderer = $('gamerenderer');
if (!isElementActuallyVisibleSafe(renderer)) return false;
const confirmSelectors = [
'#leaveconfirmwindow_okbutton',
'#leaveconfirmwindow_ok',
'#leaveconfirmwindow_yesbutton',
'#leaveconfirmwindow_yes',
'#hostleaveconfirmwindow_okbutton',
'#hostleaveconfirmwindow_ok',
'#hostleaveconfirmwindow_yesbutton',
'#hostleaveconfirmwindow_yes',
];
for (const sel of confirmSelectors) {
const btn = document.querySelector(sel);
if (btn && isVisibleActionElement(btn) && tryClickElement(btn)) return true;
}
const topExitClicked = triggerPrettyTopExitHard();
const specificSelectors = [
'#ingamewinner_continuebutton',
'#ingamewinner_continue',
'#ingamewinner_lobbybutton',
'#ingamewinner_backtolobby',
'#ingamewinner_exitbutton',
'#esc_lobbybutton',
'#esc_leavebutton',
];
for (const sel of specificSelectors) {
const btn = document.querySelector(sel);
if (btn && isVisibleActionElement(btn) && tryClickElement(btn)) return true;
}
const genericButtons = Array.from(document.querySelectorAll('button, .brownButton, .buttonShadow'))
.filter(isVisibleActionElement);
const textMatcher = /\b(return|lobby|leave|exit|quit|end game|back)\b/i;
for (const btn of genericButtons) {
const txt = String((btn.textContent || btn.innerText) || '').trim();
if (!txt || !textMatcher.test(txt)) continue;
if (tryClickElement(btn)) return true;
}
if (topExitClicked) return false;
try {
const evtDown = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true });
const evtUp = new KeyboardEvent('keyup', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true });
document.dispatchEvent(evtDown);
document.dispatchEvent(evtUp);
} catch {}
return false;
}
function scheduleHostEndGameToLobbyForGroupedWin(winEntry, groupedEntries) {
if (!winEntry || !winEntry.name || !Array.isArray(groupedEntries) || !groupedEntries.length) return;
const renderer = $('gamerenderer');
if (!isElementActuallyVisibleSafe(renderer)) return;
if (typeof isSelfLobbyHost === 'function' && !isSelfLobbyHost()) return;
const sig = `${String(winEntry.name)}|${String(winEntry.cap)}|${groupedEntries.map((e) => `${normalizeName(e.name)}:${Number(e.score) || 0}`).join(',')}`;
if (sig === lastGroupedWinnerWinSig) return;
lastGroupedWinnerWinSig = sig;
if (groupedWinnerEndRetryTimer) {
clearTimeout(groupedWinnerEndRetryTimer);
groupedWinnerEndRetryTimer = null;
}
let attempts = 0;
const runAttempt = () => {
groupedWinnerEndRetryTimer = null;
const lobbyVisible = isElementActuallyVisibleSafe($('newbonklobby'));
const rendererVisible = isElementActuallyVisibleSafe($('gamerenderer'));
if (lobbyVisible || !rendererVisible) return;
if (attemptHostEndGameToLobby()) return;
attempts += 1;
if (attempts < 8) {
groupedWinnerEndRetryTimer = setTimeout(runAttempt, 300);
}
};
groupedWinnerEndRetryTimer = setTimeout(runAttempt, 350);
}
function extractWinnerColumnLines(el) {
if (!el || !(el instanceof Element)) return [];
const normalizeLine = (txt) => String(txt || '').replace(/\s+/g, ' ').trim();
const directChildren = Array.from(el.children || []);
const directNonBreakChildren = directChildren.filter((child) => String((child && child.tagName) || '').toUpperCase() !== 'BR');
if (directNonBreakChildren.length > 1) {
const directLines = directNonBreakChildren
.map((child) => normalizeLine((child && child.textContent) || ''))
.filter(Boolean);
if (directLines.length > 1) return directLines;
}
const html = String(el.innerHTML || '');
if (/<br\s*\/?>/i.test(html)) {
const fromHtml = html
.split(/<br\s*\/?>/i)
.map((part) => normalizeLine(String(part || '').replace(/<[^>]+>/g, ' ')))
.filter(Boolean);
if (fromHtml.length) return fromHtml;
}
const out = [];
let cur = '';
const flush = () => {
const s = normalizeLine(cur);
if (s) out.push(s);
cur = '';
};
const nodes = Array.from(el.childNodes || []);
if (nodes.length) {
nodes.forEach((node) => {
if (!node) return;
const type = node.nodeType || 0;
if (type === 3) {
cur += String(node.nodeValue || '');
return;
}
if (type !== 1) return;
const tag = String((node.nodeName || '')).toUpperCase();
if (tag === 'BR') {
flush();
return;
}
const isDirectChild = node.parentNode === el;
if (isDirectChild && directNonBreakChildren.length > 1) {
flush();
cur += String((node.textContent || ''));
flush();
return;
}
cur += String((node.textContent || ''));
});
flush();
}
if (!out.length) {
const txt = String((el.innerText || el.textContent) || '')
.replace(/\r/g, '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
return txt;
}
return out;
}
function splitCollapsedWinnerScoreToken(rawToken, expectedParts, cap) {
const token = String(rawToken || '').replace(/[^\d-]/g, '');
if (!token || !Number.isFinite(expectedParts) || expectedParts < 2) return [];
let sign = 1;
let body = token;
if (body.startsWith('-')) {
sign = -1;
body = body.slice(1);
}
if (!body) return [];
const maxCap = Number.isFinite(cap) && cap > 0 ? Math.max(0, Math.floor(cap)) : 99;
const memo = new Map();
const dfs = (idx, partIdx) => {
if (partIdx === expectedParts) return idx === body.length ? [] : null;
const key = `${idx}|${partIdx}`;
if (memo.has(key)) return memo.get(key);
const remainParts = expectedParts - partIdx;
const remainChars = body.length - idx;
let best = null;
const maxLen = Math.max(1, remainChars - (remainParts - 1));
for (let len = 1; len <= maxLen; len += 1) {
const nextRemain = remainChars - len;
const minNeeded = remainParts - 1;
if (nextRemain < minNeeded) continue;
const chunk = body.slice(idx, idx + len);
const n = parseInt(chunk, 10);
if (!Number.isFinite(n) || n > maxCap) continue;
const tail = dfs(idx + len, partIdx + 1);
if (tail) {
best = [sign * n].concat(tail);
break;
}
}
memo.set(key, best);
return best;
};
const solved = dfs(0, 0);
if (solved && solved.length === expectedParts) return solved;
if (maxCap <= 9 && body.length >= expectedParts) {
const firstDigits = body
.slice(0, expectedParts)
.split('')
.map((d) => sign * (parseInt(d, 10) || 0));
if (firstDigits.length === expectedParts && firstDigits.every((n) => Number.isFinite(n) && Math.abs(n) <= maxCap)) {
return firstDigits;
}
}
return [];
}
function isLikelyKnownWinnerToken(nameText) {
const nm = String(nameText || '').trim();
if (!nm) return false;
if (isLikelyTeamWinnerName(nm)) return true;
if (isLikelyPlayerWinnerName(nm)) return true;
const key = normalizeWinnerLookupName(nm);
if (!key) return false;
const shared = Array.isArray(window.tbcSharedGroupsSnapshot) ? window.tbcSharedGroupsSnapshot : [];
for (const g of shared) {
const gName = normalizeWinnerLookupName(g && g.name);
if (gName && gName === key) return true;
}
if (Array.isArray(colorGroups)) {
for (const g of colorGroups) {
const gName = normalizeWinnerLookupName(g && g.name);
if (gName && gName === key) return true;
}
}
return false;
}
function expandCollapsedWinnerEntries(entries, rightEl) {
if (!Array.isArray(entries) || entries.length !== 1) return entries;
const row = entries[0] || {};
const rowName = String(row.name || '').trim();
if (!rowName || rowName.indexOf(':') === -1) return entries;
const rawParts = rowName
.split(':')
.map((s) => String(s || '').trim())
.filter(Boolean);
if (rawParts.length < 2) return entries;
const knownCount = rawParts.filter((p) => isLikelyKnownWinnerToken(p)).length;
if (knownCount < 2) return entries;
const cap = getWinnerRoundsToWinCap();
let scores = [];
const rightNums = rightEl
? (String((rightEl.innerText || rightEl.textContent) || '').match(/-?\d+/g) || [])
: [];
if (rightNums.length >= rawParts.length) {
scores = rightNums
.slice(0, rawParts.length)
.map((n) => parseInt(String(n || ''), 10))
.filter((n) => Number.isFinite(n));
} else if (rightNums.length === 1) {
scores = splitCollapsedWinnerScoreToken(rightNums[0], rawParts.length, cap);
if (!scores.length) {
const digits = String(rightNums[0] || '').replace(/[^\d]/g, '');
if (digits.length === rawParts.length) {
scores = digits.split('').map((d) => parseInt(d, 10));
}
}
} else {
scores = splitCollapsedWinnerScoreToken(String(Number(row.score) || 0), rawParts.length, cap);
}
if (!Array.isArray(scores) || scores.length !== rawParts.length) return entries;
const rebuilt = rawParts
.map((name, idx) => ({
name: canonicalizeWinnerName(extractChatName(name)),
score: Number(scores[idx]) || 0,
}))
.filter((e) => !!e.name);
return rebuilt.length >= 2 ? rebuilt : entries;
}
function parseWinnerEntriesFromDom(leftEl, rightEl) {
const splitNameSpans = leftEl ? Array.from(leftEl.querySelectorAll(':scope > .tbc_winner_line')) : [];
const splitScoreSpans = rightEl ? Array.from(rightEl.querySelectorAll(':scope > .tbc_winner_score_line')) : [];
if (splitNameSpans.length && splitScoreSpans.length) {
const n = Math.min(splitNameSpans.length, splitScoreSpans.length);
const out = [];
for (let i = 0; i < n; i += 1) {
const nameRaw = String(
splitNameSpans[i].dataset.playerName ||
splitNameSpans[i].textContent ||
splitNameSpans[i].innerText ||
''
).replace(/:\s*$/, '');
const nm = canonicalizeWinnerName(extractChatName(nameRaw));
const scoreRaw = String(splitScoreSpans[i].textContent || splitScoreSpans[i].innerText || '');
const score = parseInt(scoreRaw.replace(/[^\d-]/g, ''), 10);
if (!nm || !Number.isFinite(score)) continue;
out.push({ name: nm, score });
}
if (out.length) return expandCollapsedWinnerEntries(out, rightEl);
}
const leftLines = extractWinnerColumnLines(leftEl);
const rightLines = extractWinnerColumnLines(rightEl);
const out = [];
if (leftLines.length && rightLines.length) {
const lineCount = Math.min(leftLines.length, rightLines.length);
for (let i = 0; i < lineCount; i += 1) {
const nm = canonicalizeWinnerName(extractChatName(String(leftLines[i] || '').replace(/:\s*$/, '')));
const score = parseInt(String(rightLines[i] || '').replace(/[^\d-]/g, ''), 10);
if (!nm || !Number.isFinite(score)) continue;
out.push({ name: nm, score });
}
if (out.length) return expandCollapsedWinnerEntries(out, rightEl);
}
if (leftLines.length && rightEl) {
const rightNums = String((rightEl.innerText || rightEl.textContent) || '')
.match(/-?\d+/g);
if (rightNums && rightNums.length) {
const n = Math.min(leftLines.length, rightNums.length);
for (let i = 0; i < n; i += 1) {
const nm = canonicalizeWinnerName(extractChatName(String(leftLines[i] || '').replace(/:\s*$/, '')));
const score = parseInt(String(rightNums[i] || ''), 10);
if (!nm || !Number.isFinite(score)) continue;
out.push({ name: nm, score });
}
if (out.length) return expandCollapsedWinnerEntries(out, rightEl);
}
}
for (const ln of leftLines) {
const m = String(ln || '').match(/^(.*?):\s*(-?\d+)\s*$/);
if (!m) continue;
const nm = canonicalizeWinnerName(extractChatName(String(m[1] || '').trim()));
const score = parseInt(String(m[2] || '0'), 10);
if (!nm || !Number.isFinite(score)) continue;
out.push({ name: nm, score });
}
return expandCollapsedWinnerEntries(out, rightEl);
}
function isLikelyTeamWinnerName(nameText) {
const n = String(nameText || '').trim().toLowerCase();
if (!n) return false;
return (
n === 'red team' ||
n === 'blue team' ||
n === 'green team' ||
n === 'yellow team' ||
n === 'free for all' ||
n === 'spectators'
);
}
function isLikelyPlayerWinnerName(nameText) {
const n = normalizeWinnerLookupName(nameText);
if (!n || isLikelyTeamWinnerName(nameText)) return false;
const rows = document.querySelectorAll('.newbonklobby_playerentry_name');
for (const el of rows) {
const rowName = normalizeWinnerLookupName(el && el.textContent);
if (rowName && rowName === n) return true;
}
return false;
}
function renderWinnerEntries(leftEl, rightEl, entries, colorFn = null) {
leftEl.textContent = '';
if (rightEl) rightEl.textContent = '';
leftEl.dataset.tbcSplit = '1';
entries.forEach((entry, idx) => {
const line = document.createElement('span');
line.className = 'tbc_winner_line';
line.dataset.playerName = String(entry.name || '');
line.textContent = `${String(entry.name || '')}:`;
const c = colorFn ? colorFn(String(entry.name || ''), entry, idx) : null;
setElementNameColor(line, c ? String(c) : '');
leftEl.appendChild(line);
leftEl.appendChild(document.createElement('br'));
if (rightEl) {
const scoreLine = document.createElement('span');
scoreLine.className = 'tbc_winner_score_line';
scoreLine.textContent = String(entry.score);
rightEl.appendChild(scoreLine);
rightEl.appendChild(document.createElement('br'));
}
});
}
function buildGroupedWinnerEntries(sourceEntries) {
const shared = Array.isArray(window.tbcSharedGroupsSnapshot) ? window.tbcSharedGroupsSnapshot : [];
const local = Array.isArray(colorGroups)
? colorGroups
.map((g) => {
if (!g || typeof g !== 'object') return null;
const name = String(g.name || '').trim();
const color = String(g.color || '').trim();
const playersRaw = Array.isArray(g.players) ? g.players : [];
const players = playersRaw
.map((p) => {
if (typeof p === 'string') return { name: String(p || '').trim(), memberType: 'account' };
if (p && typeof p === 'object') {
const name = String(p.name || '').trim();
const memberType = normalizeMemberType(p.memberType || (p.tempGuest ? 'guest' : 'account'));
return { name, memberType: memberType === 'any' ? 'account' : memberType };
}
return null;
})
.filter((p) => p && p.name);
if (!name) return null;
return { name, color, players };
})
.filter(Boolean)
: [];
const selfNorm = normalizeName(typeof getSelfNameNorm === 'function' ? getSelfNameNorm() : '');
const syncedHostNorm = normalizeName(typeof liveGroupsSyncHostNorm === 'string' ? liveGroupsSyncHostNorm : '');
const hostBySyncedName = !!selfNorm && !!syncedHostNorm && selfNorm === syncedHostNorm;
const hostByLobbyHost =
typeof isSelfLobbyHost === 'function'
? !!isSelfLobbyHost()
: (typeof isSelfLobbyHostForRecolor === 'function' ? !!isSelfLobbyHostForRecolor() : false);
const hostLocalView =
!!window.tbcRoomGroupsSyncActive &&
local.length > 0 &&
(hostBySyncedName || hostByLobbyHost);
if (!Array.isArray(sourceEntries) || !sourceEntries.length) return null;
const normalizeWinnerLoose = (nameText) => {
return normalizeWinnerLookupName(nameText)
.replace(/[i1|]/g, 'l')
.replace(/0/g, 'o');
};
const levelStateByName = new Map();
document.querySelectorAll('.newbonklobby_playerentry').forEach((row) => {
const nameEl = row.querySelector('.newbonklobby_playerentry_name');
const lvlEl = row.querySelector('.newbonklobby_playerentry_level');
const nm = normalizeWinnerLookupName(String((nameEl && nameEl.textContent) || ''));
const lvl = String((lvlEl && lvlEl.textContent) || '').trim().toLowerCase();
if (!nm) return;
const bucket = levelStateByName.get(nm) || { guest: 0, account: 0, unknown: 0 };
if (/\bguest\b/.test(lvl)) bucket.guest += 1;
else if (lvl) bucket.account += 1;
else bucket.unknown += 1;
levelStateByName.set(nm, bucket);
});
const normalizeColorLoose = (colorText) => String(colorText || '').trim().toLowerCase();
const resolveRowMemberType = (winnerName) => {
const key = normalizeWinnerLookupName(winnerName);
const state = levelStateByName.get(key);
if (!state) return 'any';
if (state.account > 0 && state.guest === 0) return 'account';
if (state.guest > 0 && state.account === 0) return 'guest';
return 'any';
};
const buildFromSnapshot = (snapshot) => {
if (!Array.isArray(snapshot) || !snapshot.length) return null;
const playerToGroup = new Map();
const loosePlayerToGroup = new Map();
const groupsByColor = new Map();
snapshot.forEach((g, idx) => {
const gName = String((g && g.name) || '').trim();
const gColor = String((g && g.color) || '').trim();
const gToken = `g:${idx}`;
const gColorKey = normalizeColorLoose(gColor);
if (gColorKey && !groupsByColor.has(gColorKey)) groupsByColor.set(gColorKey, { name: gName, color: gColor, token: gToken });
const players = Array.isArray(g && g.players) ? g.players : [];
players.forEach((p) => {
const pName = typeof p === 'string' ? p : String((p && p.name) || '');
const pType = getLookupTypeSuffix(getGroupPlayerMemberType(p));
const norm = normalizeWinnerLookupName(pName);
if (!norm || !gName) return;
const typedKey = `${norm}|${pType}`;
playerToGroup.set(typedKey, { name: gName, color: gColor, token: gToken });
const loose = normalizeWinnerLoose(pName);
const typedLoose = loose ? `${loose}|${pType}` : '';
if (typedLoose && !loosePlayerToGroup.has(typedLoose)) loosePlayerToGroup.set(typedLoose, { name: gName, color: gColor, token: gToken });
});
});
const grouped = new Map();
const matchedIndexes = new Set();
let matched = 0;
sourceEntries.forEach((row, rowIdx) => {
const directNorm = normalizeWinnerLookupName(row.name);
const rowType = resolveRowMemberType(row.name);
const looseNorm = normalizeWinnerLoose(row.name);
let grp = null;
const typedTryOrder = rowType === 'any' ? ['account', 'guest'] : [rowType];
for (const t of typedTryOrder) {
const typedDirect = `${directNorm}|${t}`;
grp = playerToGroup.get(typedDirect);
if (grp) break;
const typedLoose = `${looseNorm}|${t}`;
grp = loosePlayerToGroup.get(typedLoose);
if (grp) break;
}
if (!grp) {
const mappedColor = normalizeColorLoose(getDisplayColorForName(row.name, { memberType: rowType }) || '');
if (mappedColor) grp = groupsByColor.get(mappedColor) || null;
}
if (!grp) return;
matched += 1;
matchedIndexes.add(rowIdx);
const key = String(grp.token || normalizeName(grp.name) || `${grp.name}|${grp.color || ''}`);
const prev = grouped.get(key) || {
name: grp.name,
score: 0,
color: grp.color || '',
contributors: [],
_contribIndex: new Map(),
isUngroupedPlayer: false,
};
const add = Number(row.score) || 0;
prev.score += add;
if (!prev.color && grp.color) prev.color = grp.color;
const contribName = String(row.name || '').trim();
const contribType = resolveRowMemberType(contribName);
const contribKey = `${normalizeWinnerLookupName(contribName) || normalizeName(contribName) || contribName.toLowerCase()}|${contribType}`;
const existingContribIdx = prev._contribIndex.get(contribKey);
if (existingContribIdx === undefined) {
prev.contributors.push({ name: contribName, score: add });
prev._contribIndex.set(contribKey, prev.contributors.length - 1);
} else {
prev.contributors[existingContribIdx].score += add;
}
grouped.set(key, prev);
});
if (!matched || !grouped.size) return null;
const ungroupedRows = sourceEntries
.map((row, rowIdx) => ({ row, rowIdx }))
.filter(({ row, rowIdx }) => {
if (!row || matchedIndexes.has(rowIdx)) return false;
if (isLikelyTeamWinnerName(String(row.name || ''))) return false;
return true;
})
.map(({ row }) => ({
name: String((row && row.name) || '').trim(),
score: Number((row && row.score) || 0),
color: '',
contributors: [],
isUngroupedPlayer: true,
}))
.filter((row) => !!row.name);
return {
matched,
rows: Array.from(grouped.values())
.map((row) => {
const contributors = Array.isArray(row.contributors)
? row.contributors
.slice()
.sort((a, b) => (Number(b.score) || 0) - (Number(a.score) || 0) || String(a.name || '').localeCompare(String(b.name || '')))
: [];
return {
name: row.name,
score: row.score,
color: row.color || '',
contributors,
isUngroupedPlayer: false,
};
})
.sort((a, b) => (b.score - a.score) || a.name.localeCompare(b.name))
.concat(ungroupedRows),
};
};
const candidateResults = [];
const sharedResult = buildFromSnapshot(shared);
if (sharedResult) candidateResults.push({ source: 'shared', ...sharedResult });
if (hostLocalView) {
const localResult = buildFromSnapshot(local);
if (localResult) candidateResults.push({ source: 'local', ...localResult });
}
if (!candidateResults.length) return null;
candidateResults.sort((a, b) => {
if (b.matched !== a.matched) return b.matched - a.matched;
if (a.source !== b.source) return a.source === 'shared' ? -1 : 1;
return 0;
});
return candidateResults[0].rows;
}
function applyWinnerNameColors() {
const left = document.getElementById('ingamewinner_scores_left');
if (!left) return;
const right = document.getElementById('ingamewinner_scores_right');
const winnerBoardOn = !!recolorDisplaySettings.winnerBoard;
const hasGroups = hasDisplayGroupsForNames();
let parsed = parseWinnerEntriesFromDom(left, right);
const hasPlayerLikeRows = parsed.some((e) => !isLikelyTeamWinnerName(e.name));
const sharedSyncActive = !!window.tbcRoomGroupsSyncActive;
const winnerBoardColorActive = winnerBoardOn || sharedSyncActive;
const shouldGroup = sharedSyncActive && (isTeamsOffForWinnerBoard() || hasPlayerLikeRows);
const splitLineCountBefore = left.querySelectorAll(':scope > .tbc_winner_line').length;
if (
!shouldGroup &&
winnerBoardColorActive &&
parsed.length &&
(left.dataset.tbcSplit !== '1' || splitLineCountBefore === 0)
) {
renderWinnerEntries(left, right, parsed, (nm) => getWinnerBoardColorForName(nm));
parsed = parseWinnerEntriesFromDom(left, right);
}
const hasNativePlayerRows = parsed.some((e) => isLikelyPlayerWinnerName(e.name));
const hasNonTeamRows = parsed.some((e) => !isLikelyTeamWinnerName(e.name));
if (parsed.length && ((sharedSyncActive && hasNativePlayerRows) || (!sharedSyncActive && hasNonTeamRows))) {
winnerSourceEntries = parsed.map((e) => ({ name: e.name, score: e.score }));
}
if (shouldGroup) {
const source = winnerSourceEntries.length ? winnerSourceEntries : parsed;
const grouped = buildGroupedWinnerEntries(source);
if (grouped && grouped.length) {
const groupedColorFn = (groupName, entry) => {
if (entry && entry.isUngroupedPlayer) return '';
if (entry && entry.color) return entry.color;
return getDisplayColorForName(groupName) || '';
};
renderWinnerEntries(left, right, grouped, winnerBoardColorActive ? groupedColorFn : null);
lastGroupedWinnerWinSig = '';
if (winnerBoardColorActive) applyWinnerHeadlineColor(groupedColorFn);
else clearWinnerHeadlineColor();
left.dataset.tbcWinnerGrouped = '1';
winnerGroupedMode = true;
return;
}
}
if (winnerGroupedMode) {
const source = winnerSourceEntries.length ? winnerSourceEntries : parsed;
if (source.length) {
const colorFn = winnerBoardColorActive ? (nm) => getWinnerBoardColorForName(nm) : null;
renderWinnerEntries(left, right, source, colorFn);
}
delete left.dataset.tbcWinnerGrouped;
winnerGroupedMode = false;
}
lastGroupedWinnerWinSig = '';
if (!winnerBoardColorActive) {
if (left.dataset.tbcSplit === '1') {
left.querySelectorAll(':scope > .tbc_winner_line').forEach((lineEl) => {
setElementNameColor(lineEl, '');
});
}
clearWinnerHeadlineColor();
return;
}
if (left.dataset.tbcSplit === '1') {
const existingLines = left.querySelectorAll(':scope > .tbc_winner_line');
if (existingLines.length) {
const lineColorByName = new Map();
existingLines.forEach((lineEl) => {
const nm = lineEl.dataset.playerName || '';
const color = getWinnerBoardColorForName(nm);
setElementNameColor(lineEl, color || '');
const key = normalizeWinnerLookupName(nm);
if (key && color) lineColorByName.set(key, color);
});
applyWinnerHeadlineColor((winnerName) => {
const key = normalizeWinnerLookupName(winnerName);
if (key && lineColorByName.has(key)) return lineColorByName.get(key) || null;
return getWinnerBoardColorForName(winnerName);
});
return;
}
}
if (parsed.length) {
renderWinnerEntries(left, right, parsed, (nm) => getWinnerBoardColorForName(nm));
const rebuiltLines = left.querySelectorAll(':scope > .tbc_winner_line');
if (rebuiltLines.length) {
const lineColorByName = new Map();
rebuiltLines.forEach((lineEl) => {
const nm = lineEl.dataset.playerName || '';
const color = getWinnerBoardColorForName(nm);
setElementNameColor(lineEl, color || '');
const key = normalizeWinnerLookupName(nm);
if (key && color) lineColorByName.set(key, color);
});
applyWinnerHeadlineColor((winnerName) => {
const key = normalizeWinnerLookupName(winnerName);
if (key && lineColorByName.has(key)) return lineColorByName.get(key) || null;
return getWinnerBoardColorForName(winnerName);
});
return;
}
}
applyWinnerHeadlineColor((winnerName) => {
return getWinnerBoardColorForName(winnerName);
});
}
function setupWinnerNameColorObservers() {
const idsToWatch = [
'ingamewinner_scores_left',
'ingamewinner',
'ingamewinner_container',
];
idsToWatch.forEach((id) => {
waitForElement(id, (el) => {
const obs = new MutationObserver(() => scheduleWinnerScan());
obs.observe(el, { childList: true, subtree: true, characterData: true });
scheduleWinnerScan();
});
});
window.addEventListener('recolorGroupsChanged', () => scheduleWinnerScan());
window.addEventListener('tbcSharedGroupsChanged', () => {
lastSharedColorSig = '';
sharedColorCache.clear();
scheduleWinnerScan();
});
window.addEventListener('tbcSharedSyncStateChanged', () => scheduleWinnerScan());
scheduleWinnerScan();
}
setupWinnerNameColorObservers();
function setupLobbyNameColorObservers() {
installLobbyPlayerMenuOwnerTracking();
const playerListIdsToWatch = [
'newbonklobby_playerbox_elementcontainer',
'newbonklobby_playerbox_leftelementcontainer',
'newbonklobby_playerbox_rightelementcontainer',
'newbonklobby_specbox_elementcontainer',
];
const chatIdsToWatch = [
'newbonklobby_chat_content',
'ingamechatcontent',
];
playerListIdsToWatch.forEach((id) => {
waitForElement(id, (el) => {
const obs = new MutationObserver((mutations) => {
const touched = new Set();
for (const m of mutations) {
const collectFrom = (node) => {
if (!(node instanceof Element)) return;
if (node.classList && node.classList.contains('newbonklobby_playerentry')) {
touched.add(node);
return;
}
const row = node.closest ? node.closest('.newbonklobby_playerentry') : null;
if (row) touched.add(row);
if (node.querySelectorAll) {
node.querySelectorAll('.newbonklobby_playerentry').forEach((r) => touched.add(r));
}
};
if (m.type === 'childList') {
m.addedNodes.forEach((n) => collectFrom(n));
if (m.target instanceof Element) {
const row = m.target.closest ? m.target.closest('.newbonklobby_playerentry') : null;
if (row) touched.add(row);
}
} else if (m.type === 'characterData') {
const p = m.target && m.target.parentElement ? m.target.parentElement : null;
collectFrom(p);
}
}
touched.forEach((row) => applyLobbyPlayerEntryColor(row, false, true, false));
const menuColor = getPlayerListMenuGroupColor();
const playerMenus = document.querySelectorAll('.newbonklobby_playerentry_menu, .newbonklobby_playerentry_menu_submenu');
playerMenus.forEach((menuEl) => setPlayerListMenuBackground(menuEl, menuColor));
});
obs.observe(el, { childList: true, subtree: true, characterData: true });
const rows = el.querySelectorAll('.newbonklobby_playerentry');
rows.forEach((row) => applyLobbyPlayerEntryColor(row, false, true, false));
});
});
chatIdsToWatch.forEach((id) => {
waitForElement(id, (el) => {
const obs = new MutationObserver(() => scheduleLobbyScan());
obs.observe(el, { childList: true, subtree: true, characterData: true });
scheduleLobbyScan();
});
});
waitForElement('newbonklobby', (el) => {
const obs = new MutationObserver(() => scheduleLobbyScan());
obs.observe(el, { attributes: true, attributeFilter: ['style', 'class'] });
});
const applyPlayerColorGroupUpdate = (players, clearSharedCache = false) => {
const changed = invalidateLobbyPersistentColorsForPlayers(players, clearSharedCache);
if (changed.length) refreshLobbyPlayerEntriesForNames(changed, true);
scheduleLobbyScan();
};
window.addEventListener('recolorGroupsChanged', () => {
scheduleLobbyScan();
});
window.addEventListener('tbcGroupMembershipChanged', (e) => {
const players = e && e.detail && Array.isArray(e.detail.players) ? e.detail.players : [];
applyPlayerColorGroupUpdate(players, false);
});
window.addEventListener('tbcGroupColorChanged', (e) => {
const players = e && e.detail && Array.isArray(e.detail.players) ? e.detail.players : [];
applyPlayerColorGroupUpdate(players, false);
});
window.addEventListener('tbcGroupDeleted', (e) => {
const players = e && e.detail && Array.isArray(e.detail.players) ? e.detail.players : [];
applyPlayerColorGroupUpdate(players, false);
});
window.addEventListener('tbcSharedGroupsChanged', (e) => {
const players = e && e.detail && Array.isArray(e.detail.changedPlayers) ? e.detail.changedPlayers : [];
if (!players.length || !!window.tbcRoomGroupsSyncActive) {
refreshLobbyPersistentColorsFromGroups();
applyLobbyNameColors(true);
scheduleLobbyScan();
return;
}
applyPlayerColorGroupUpdate(players, true);
});
window.addEventListener('tbcSharedSyncStateChanged', () => {
refreshLobbyPersistentColorsFromGroups();
applyLobbyNameColors(true);
scheduleLobbyScan();
});
scheduleLobbyScan();
}
setupLobbyNameColorObservers();
function extractChatName(rawText) {
let s = String(rawText || '');
s = s.replace(/[\s\u00A0\u2000-\u200B\u202F\u205F\u3000]+$/g, '');
s = s.replace(/[:\uFF1A]+$/g, '');
return s.trim();
}
function normalizeName(name) {
return (name || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function namesEquivalent(a, b) {
const aNorm = normalizeName(a);
const bNorm = normalizeName(b);
if (aNorm && bNorm && aNorm === bNorm) return true;
const aCanon = normalizeWinnerLookupName(a);
const bCanon = normalizeWinnerLookupName(b);
return !!(aCanon && bCanon && aCanon === bCanon);
}
function normalizeMemberType(type) {
const t = String(type || '').trim().toLowerCase();
if (t === 'guest') return 'guest';
if (t === 'account' || t === 'level' || t === 'player') return 'account';
return 'any';
}
function getGroupPlayerMemberType(player) {
if (player && typeof player === 'object') {
const explicit = normalizeMemberType(player.memberType);
if (explicit !== 'any') return explicit;
if (player.tempGuest === true) return 'guest';
}
return 'account';
}
function memberIdentityMatches(player, playerName, memberType = 'any') {
const targetType = normalizeMemberType(memberType);
const pName = typeof player === 'string' ? player : String((player && player.name) || '');
if (!namesEquivalent(pName, playerName)) return false;
if (targetType === 'any') return true;
return getGroupPlayerMemberType(player) === targetType;
}
function getLookupTypeSuffix(memberType) {
const t = normalizeMemberType(memberType);
return t === 'any' ? 'any' : t;
}
function clampInt(n, min, max) {
const x = Number.isFinite(n) ? Math.trunc(n) : parseInt(String(n), 10);
if (!Number.isFinite(x)) return min;
return Math.max(min, Math.min(max, x));
}
function getPrettyTopAccountIdentity() {
const nameEl = $('pretty_top_name');
const lvlEl = $('pretty_top_level');
const nameRaw = String(nameEl ? nameEl.textContent : '').trim();
const lvlRaw = String(lvlEl ? lvlEl.textContent : '').trim();
const nameNorm = normalizeName(nameRaw);
const lvlNorm = lvlRaw.toLowerCase();
if (!nameNorm || nameNorm === 'guest') return null;
if (!lvlNorm || /\bguest\b/.test(lvlNorm)) return null;
const looksLoggedIn = /^level\b/.test(lvlNorm) || /^lv\b/.test(lvlNorm) || /\d/.test(lvlNorm);
if (!looksLoggedIn) return null;
return { name: nameNorm, level: lvlNorm };
}
function isLoggedInAccount() {
return !!getPrettyTopAccountIdentity();
}
function getAccountNameFromPrettyTopOrNull() {
const ident = getPrettyTopAccountIdentity();
return ident ? ident.name : null;
}
function getStorageKeyV2() {
const acct = getAccountNameFromPrettyTopOrNull();
if (!acct) return null;
return STORAGE_KEY_PREFIX_V2 + acct;
}
function getDisplaySettingsStorageKeyV1() {
const acct = getAccountNameFromPrettyTopOrNull();
if (!acct) return null;
return DISPLAY_SETTINGS_KEY_PREFIX_V1 + acct;
}
function guessOldV1StorageKeys() {
const keys = new Set();
const prettyName = $('pretty_top_name');
if (prettyName && prettyName.textContent.trim()) {
keys.add(STORAGE_KEY_PREFIX_V1 + normalizeName(prettyName.textContent.trim()));
}
const stored = localStorage.getItem('bonk_name');
if (stored && stored.trim()) keys.add(STORAGE_KEY_PREFIX_V1 + normalizeName(stored.trim()));
keys.add(STORAGE_KEY_PREFIX_V1 + 'default');
return Array.from(keys);
}
function migrateV1ToV2IfNeeded() {
try {
if (!storageKey) return;
const v2Raw = localStorage.getItem(storageKey);
if (v2Raw) return;
for (const k of guessOldV1StorageKeys()) {
const raw = localStorage.getItem(k);
if (!raw) continue;
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
localStorage.setItem(storageKey, raw);
return;
}
} catch {}
}
} catch {}
}
function hexToRgba(hex, alpha) {
if (!hex) return `rgba(0,0,0,${alpha})`;
let h = String(hex).replace('#', '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (h.length !== 6) return `rgba(0,0,0,${alpha})`;
const r = parseInt(h.slice(0, 2), 16) || 0;
const g = parseInt(h.slice(2, 4), 16) || 0;
const b = parseInt(h.slice(4, 6), 16) || 0;
return `rgba(${r},${g},${b},${alpha})`;
}
function migrateAndNormalizeGroups(arr) {
if (!Array.isArray(arr)) return [];
return arr
.map((g) => {
if (!g || typeof g !== 'object') return null;
const id = String(g.id || `cg_${Date.now()}_${Math.random().toString(16).slice(2)}`);
const name = String(g.name || 'Group');
const color = String(g.color || getRandomPresetColor());
let players = [];
if (Array.isArray(g.players)) {
if (g.players.length && typeof g.players[0] === 'string') {
players = g.players
.map((p) => String(p || '').trim())
.filter(Boolean)
.map((p) => ({ name: p, memberType: 'account' }));
} else {
players = g.players
.map((p) => {
if (!p) return null;
if (typeof p === 'string') return { name: p.trim(), memberType: 'account' };
if (typeof p === 'object') {
const nm = String(p.name || '').trim();
if (!nm) return null;
const memberType = normalizeMemberType(p.memberType || (p.tempGuest ? 'guest' : 'account'));
const tempGuest = !!p.tempGuest || memberType === 'guest';
return { name: nm, memberType: memberType === 'any' ? 'account' : memberType, tempGuest };
}
return null;
})
.filter(Boolean);
}
}
return { id, name, color, players };
})
.filter(Boolean);
}
function normalizeRecolorDisplaySettings(data) {
const defaults = createDefaultRecolorDisplaySettings();
if (!data || typeof data !== 'object') return defaults;
return {
playerNames: typeof data.playerNames === 'boolean' ? data.playerNames : defaults.playerNames,
winnerBoard: typeof data.winnerBoard === 'boolean' ? data.winnerBoard : defaults.winnerBoard,
ingameChatNames: typeof data.ingameChatNames === 'boolean' ? data.ingameChatNames : defaults.ingameChatNames,
lobbyChatNames: typeof data.lobbyChatNames === 'boolean' ? data.lobbyChatNames : defaults.lobbyChatNames,
playerListNames: typeof data.playerListNames === 'boolean' ? data.playerListNames : defaults.playerListNames,
playerListBackboard: typeof data.playerListBackboard === 'boolean' ? data.playerListBackboard : defaults.playerListBackboard,
};
}
function loadGroups() {
try {
if (!storageKey) {
colorGroups = [];
return;
}
const raw = localStorage.getItem(storageKey);
if (!raw) {
colorGroups = [];
return;
}
const data = JSON.parse(raw);
colorGroups = migrateAndNormalizeGroups(data);
} catch (e) {
console.error('[Re:Color] Failed to load groups:', e);
colorGroups = [];
}
}
function saveGroups() {
try {
if (!storageKey) {
window.dispatchEvent(new Event('recolorGroupsChanged'));
updateStorageHintUI();
return;
}
const persistable = (Array.isArray(colorGroups) ? colorGroups : []).map((g) => {
const players = Array.isArray(g && g.players)
? g.players
.filter((p) => !(p && typeof p === 'object' && p.tempGuest === true))
.map((p) => {
if (typeof p === 'string') return { name: String(p || '').trim(), memberType: 'account' };
const name = String((p && p.name) || '').trim();
return { name, memberType: 'account' };
})
.filter((p) => p.name)
: [];
return {
id: String((g && g.id) || `cg_${Date.now()}_${Math.random().toString(16).slice(2)}`),
name: String((g && g.name) || 'Group'),
color: String((g && g.color) || getRandomPresetColor()),
players,
};
});
localStorage.setItem(storageKey, JSON.stringify(persistable));
window.dispatchEvent(new Event('recolorGroupsChanged'));
updateStorageHintUI();
} catch (e) {
console.error('[Re:Color] Failed to save groups:', e);
}
}
function loadRecolorDisplaySettings() {
try {
const key = getDisplaySettingsStorageKeyV1();
if (!key) {
recolorDisplaySettings = createDefaultRecolorDisplaySettings();
return;
}
const raw = localStorage.getItem(key);
if (!raw) {
recolorDisplaySettings = createDefaultRecolorDisplaySettings();
return;
}
const parsed = JSON.parse(raw);
recolorDisplaySettings = normalizeRecolorDisplaySettings(parsed);
} catch (e) {
console.error('[Re:Color] Failed to load display settings:', e);
recolorDisplaySettings = createDefaultRecolorDisplaySettings();
}
}
function saveRecolorDisplaySettings() {
try {
const key = getDisplaySettingsStorageKeyV1();
if (key) localStorage.setItem(key, JSON.stringify(recolorDisplaySettings));
} catch (e) {
console.error('[Re:Color] Failed to save display settings:', e);
}
window.dispatchEvent(new Event('recolorGroupsChanged'));
applyLobbyNameColors();
applyWinnerNameColors();
if (typeof refreshRecolorSettingsUi === 'function') refreshRecolorSettingsUi();
}
function updateStorageHintUI() {
const el = $('recolor_storage_hint');
if (!el) return;
if (storageKey) {
el.style.color = '';
el.style.opacity = '0.75';
el.textContent = `Per-account storage: ${storageKey}`;
} else {
el.style.color = '#ffcc66';
el.style.opacity = '0.9';
el.textContent = 'Guest mode: settings are temporary until you log in.';
}
}
function updateAccountStorageKey() {
const newKey = getStorageKeyV2();
if (newKey === lastStorageKey) return;
lastStorageKey = newKey;
storageKey = newKey;
if (storageKey) migrateV1ToV2IfNeeded();
loadGroups();
loadRecolorDisplaySettings();
lobbyPersistentColorEpoch += 1;
lobbyPersistentColorByName.clear();
lobbyPersistentColorVersionByName.clear();
colorCache.clear();
if ($('cg_groups_list')) renderGroupsUI();
updateStorageHintUI();
if (typeof refreshRecolorSettingsUi === 'function') refreshRecolorSettingsUi();
applyLobbyNameColors();
applyWinnerNameColors();
}
function ensureAccountObservers() {
if (observersInitialized) return;
observersInitialized = true;
const attach = () => {
const nameEl = $('pretty_top_name');
const lvlEl = $('pretty_top_level');
const obs = new MutationObserver(() => updateAccountStorageKey());
if (nameEl) obs.observe(nameEl, { childList: true, characterData: true, subtree: true });
if (lvlEl) obs.observe(lvlEl, { childList: true, characterData: true, subtree: true });
};
attach();
updateAccountStorageKey();
}
function getColorForName(name, opts = null) {
const memberType = normalizeMemberType(opts && opts.memberType);
const key = normalizeName(name);
const canonicalKey = normalizeWinnerLookupName(name);
if (!key && !canonicalKey) return null;
const cacheKey = `${canonicalKey || key}|${getLookupTypeSuffix(memberType)}`;
if (cacheKey && colorCache.has(cacheKey)) return colorCache.get(cacheKey);
let found = null;
if (memberType === 'any') {
for (const passType of ['account', 'guest']) {
for (const g of colorGroups) {
if ((g.players || []).some((p) => memberIdentityMatches(p, name, passType))) {
found = g.color;
break;
}
}
if (found) break;
}
} else {
for (const g of colorGroups) {
if ((g.players || []).some((p) => memberIdentityMatches(p, name, memberType))) {
found = g.color;
break;
}
}
}
if (cacheKey) colorCache.set(cacheKey, found);
return found;
}
function findGroupByPlayerName(playerName, memberType = 'any') {
return colorGroups.find((g) => (g.players || []).some((p) => memberIdentityMatches(p, playerName, memberType))) || null;
}
function isPlayerInAnyGroup(playerName, memberType = 'any') {
if (!normalizeName(playerName)) return false;
return colorGroups.some((g) => (g.players || []).some((p) => memberIdentityMatches(p, playerName, memberType)));
}
function findPlayerInGroup(groupId, playerName, memberType = 'any') {
const g = colorGroups.find((x) => x.id === groupId);
if (!g) return null;
return (g.players || []).find((p) => memberIdentityMatches(p, playerName, memberType)) || null;
}
function isTemporaryGuestGroupMemberPlayer(player) {
return !!(player && typeof player === 'object' && player.tempGuest === true);
}
function isLobbyGuestName(name, lobbyInfo = null) {
const nm = normalizeName(name);
if (!nm) return false;
const info = lobbyInfo || getLobbyAccountInfoCached(500);
const guestSet = info && info.guestSet ? info.guestSet : new Set();
const levelMap = info && info.levelMap ? info.levelMap : new Map();
return guestSet.has(nm) || String(levelMap.get(nm) || '') === 'guest';
}
function shouldShowGuestWarningForGroupMember(player, lobbyInfo = null) {
if (isTemporaryGuestGroupMemberPlayer(player)) return true;
if (getGroupPlayerMemberType(player) === 'account') return false;
const name = typeof player === 'string' ? player : String((player && player.name) || '');
return isLobbyGuestName(name, lobbyInfo);
}
function purgeTemporaryGuestGroupMembers({ onlyMissingInLobby = false, lobbyInfo = null } = {}) {
const info = lobbyInfo || getLobbyAccountInfoCached(500);
const guestSet = info && info.guestSet ? info.guestSet : new Set();
let removed = 0;
let selectedCleared = false;
colorGroups.forEach((g) => {
const beforePlayers = Array.isArray(g && g.players) ? g.players : [];
const nextPlayers = beforePlayers.filter((p) => {
if (!isTemporaryGuestGroupMemberPlayer(p)) return true;
if (onlyMissingInLobby) {
const nm = normalizeName(p && p.name);
if (nm && guestSet.has(nm)) return true;
}
removed += 1;
if (selectedPlayer && memberIdentityMatches(selectedPlayer, p && p.name, 'guest')) selectedCleared = true;
return false;
});
g.players = nextPlayers;
});
if (!removed) return 0;
if (selectedCleared) selectedPlayer = null;
saveGroups();
renderGroupsUI();
renderSelectedPlayerPanel();
if (groupsPanelVisible) renderGroupsPanel();
applyLobbyNameColors(true);
return removed;
}
function addGroup(name = 'New Group', color = null) {
const id = `cg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const pickedColor = color ? String(color) : getRandomPresetColor();
colorGroups.push({ id, name, color: pickedColor, players: [] });
saveGroups();
renderGroupsUI();
}
function deleteGroup(id) {
const idx = colorGroups.findIndex((g) => g.id === id);
if (idx === -1) return;
const removedPlayers = (colorGroups[idx].players || []).map((p) => String((p && p.name) || '').trim()).filter(Boolean);
colorGroups.splice(idx, 1);
saveGroups();
if (removedPlayers.length) {
window.dispatchEvent(new CustomEvent('tbcGroupDeleted', {
detail: { players: removedPlayers }
}));
}
renderGroupsUI();
renderSelectedPlayerPanel();
}
function renameGroup(id, newName) {
const g = colorGroups.find((x) => x.id === id);
if (!g) return;
g.name = newName || g.name;
saveGroups();
renderGroupsUI();
renderSelectedPlayerPanel();
}
function setGroupColor(id, newColor) {
const g = colorGroups.find((x) => x.id === id);
if (!g) return;
g.color = String(newColor || g.color || '').trim();
saveGroups();
window.dispatchEvent(new CustomEvent('tbcGroupColorChanged', {
detail: { players: (g.players || []).map((p) => String((p && p.name) || '').trim()).filter(Boolean) }
}));
}
function addPlayerToGroup(groupId, playerName, opts = {}) {
const name = (playerName || '').trim();
if (!name) return { ok: false, error: 'Name cannot be empty.' };
const requestedType = normalizeMemberType(opts.memberType);
const existingGroup = findGroupByPlayerName(name, requestedType === 'any' ? 'account' : requestedType);
if (existingGroup) {
return { ok: false, error: `Player "${name}" is already in group "${existingGroup.name}".` };
}
const g = colorGroups.find((x) => x.id === groupId);
if (!g) return { ok: false, error: 'Group not found.' };
const info = getLobbyAccountInfoCached(500);
const guestSet = info && info.guestSet ? info.guestSet : new Set();
const isGuestNow = guestSet.has(normalizeName(name));
const resolvedType = requestedType !== 'any'
? requestedType
: ((opts && opts.allowGuest && isGuestNow) ? 'guest' : 'account');
if (resolvedType === 'guest' && !opts.allowGuest) {
return { ok: false, error: 'Guests can only be added from the player list.' };
}
g.players.push({ name, memberType: resolvedType, tempGuest: resolvedType === 'guest' });
saveGroups();
window.dispatchEvent(new CustomEvent('tbcGroupMembershipChanged', {
detail: { players: [name], action: 'add' }
}));
renderGroupsUI();
renderSelectedPlayerPanel();
return { ok: true };
}
function removePlayerFromGroup(groupId, playerName, memberType = 'any') {
const g = colorGroups.find((x) => x.id === groupId);
if (!g) return;
const beforeCount = (g.players || []).length;
g.players = (g.players || []).filter((p) => !memberIdentityMatches(p, playerName, memberType));
if (selectedPlayer && selectedPlayer.groupId === groupId && memberIdentityMatches(selectedPlayer, playerName, memberType)) {
selectedPlayer = null;
}
saveGroups();
if ((g.players || []).length !== beforeCount) {
window.dispatchEvent(new CustomEvent('tbcGroupMembershipChanged', {
detail: { players: [playerName], action: 'remove' }
}));
}
renderGroupsUI();
renderSelectedPlayerPanel();
}
function removePlayerFromAllGroups(playerName, memberType = 'any') {
if (!normalizeName(playerName)) return { ok: false, removed: 0 };
let removed = 0;
colorGroups.forEach((g) => {
const before = (g.players || []).length;
g.players = (g.players || []).filter((p) => !memberIdentityMatches(p, playerName, memberType));
removed += Math.max(0, before - (g.players || []).length);
});
if (!removed) return { ok: false, removed: 0 };
if (selectedPlayer && memberIdentityMatches(selectedPlayer, playerName, memberType)) {
selectedPlayer = null;
}
saveGroups();
window.dispatchEvent(new CustomEvent('tbcGroupMembershipChanged', {
detail: { players: [playerName], action: 'remove' }
}));
renderGroupsUI();
renderSelectedPlayerPanel();
return { ok: true, removed };
}
function movePlayerToGroup(fromGroupId, toGroupId, playerName, memberType = 'any') {
if (fromGroupId === toGroupId) return { ok: false, error: 'Already in that group.' };
const from = colorGroups.find((x) => x.id === fromGroupId);
const to = colorGroups.find((x) => x.id === toGroupId);
if (!from || !to) return { ok: false, error: 'Group not found.' };
const existing = (to.players || []).some((p) => memberIdentityMatches(p, playerName, memberType));
if (existing) return { ok: false, error: `Player "${playerName}" is already in that group.` };
const p = (from.players || []).find((pp) => memberIdentityMatches(pp, playerName, memberType));
if (!p) return { ok: false, error: 'Player not found.' };
from.players = (from.players || []).filter((pp) => !memberIdentityMatches(pp, playerName, memberType));
to.players.push({ name: p.name, memberType: getGroupPlayerMemberType(p), tempGuest: !!p.tempGuest });
if (selectedPlayer && memberIdentityMatches(selectedPlayer, playerName, memberType)) selectedPlayer.groupId = toGroupId;
saveGroups();
window.dispatchEvent(new CustomEvent('tbcGroupMembershipChanged', {
detail: { players: [p.name], action: 'move' }
}));
renderGroupsUI();
renderSelectedPlayerPanel();
return { ok: true };
}
function reorderGroupsToIndex(groupId, targetIndex) {
const oldIndex = colorGroups.findIndex((g) => g.id === groupId);
if (oldIndex === -1) return;
if (targetIndex < 0) targetIndex = 0;
if (targetIndex > colorGroups.length - 1) targetIndex = colorGroups.length - 1;
if (oldIndex === targetIndex) return;
const [moved] = colorGroups.splice(oldIndex, 1);
colorGroups.splice(targetIndex, 0, moved);
saveGroups();
renderGroupsUI();
renderSelectedPlayerPanel();
}
function closePanel() {
if (activePanel && activePanel.parentNode) activePanel.parentNode.removeChild(activePanel);
activePanel = null;
}
function openPanel(anchorEl, buildContent) {
closePanel();
const panel = document.createElement('div');
panel.className = 'mod_ctx_panel';
panel.addEventListener('click', (e) => e.stopPropagation());
buildContent(panel);
document.body.appendChild(panel);
activePanel = panel;
const rect = anchorEl.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom + 4;
if (left + panelRect.width > window.innerWidth) left = window.innerWidth - panelRect.width - 8;
if (top + panelRect.height > window.innerHeight) top = rect.top - panelRect.height - 4;
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
}
document.addEventListener('click', () => closePanel());
function ensureRecolorStyles() {
if ($('recolor_css')) return;
const style = document.createElement('style');
style.id = 'recolor_css';
style.textContent = `
#cg_groups_outer {
margin-top: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 4px;
box-sizing: border-box;
}
#cg_groups_list {
display: flex;
flex-direction: row;
gap: 10px;
min-height: 180px;
}
.cg_group {
position: relative;
border: 1px solid rgba(0,0,0,0.4);
border-radius: 6px;
padding: 8px;
background: rgba(0,0,0,0.15);
box-shadow: 0 2px 4px rgba(0,0,0,0.25);
display: flex;
flex-direction: column;
cursor: default;
flex: 0 0 33%;
max-width: 33%;
box-sizing: border-box;
height: 220px;
}
.cg_group_header {
display: flex;
align-items: center;
margin-bottom: 6px;
padding: 2px 4px;
border-radius: 4px;
background: rgba(0,0,0,0.2);
}
.cg_group_handle {
width: 16px;
height: 16px;
margin-right: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
opacity: 0.8;
font-size: 10px;
user-select: none;
}
.cg_group_title {
flex: 1;
font-weight: bold;
font-size: 13px;
text-align: left;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.cg_group_menu {
cursor: pointer;
opacity: 0.8;
padding: 2px 6px;
border-radius: 4px;
}
.cg_group_menu:hover { background: rgba(0,0,0,0.2); }
.cg_group_meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
opacity: .9;
margin: 2px 0 6px 0;
padding: 6px 6px;
border-radius: 6px;
background: rgba(0,0,0,0.12);
border: 1px solid rgba(255,255,255,0.08);
}
.cg_group_meta_line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.cg_group_meta_line_right {
justify-content: flex-end;
font-family: monospace;
opacity: .85;
}
.cg_group_players {
margin: 0 0 4px 0;
max-height: 110px;
overflow-y: auto;
padding-right: 4px;
flex: 1 1 auto;
}
.cg_player_row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 3px 6px;
border-radius: 6px;
background: rgba(0,0,0,0.15);
margin-bottom: 3px;
font-size: 12px;
cursor: pointer;
}
.cg_player_row:nth-child(even) { background: rgba(0,0,0,0.28); }
.cg_player_row:last-child { margin-bottom: 0; }
.cg_player_row.selected { outline: 1px solid rgba(121,85,248,0.6); background: rgba(121,85,248,0.12); }
.cg_player_name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
display: inline-flex;
align-items: center;
gap: 4px;
}
.tbc_guest_warn_badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 999px;
background: #f1d34f;
color: #3a2f08;
border: 1px solid rgba(58, 47, 8, 0.35);
font-size: 10px;
font-weight: 800;
line-height: 1;
flex: 0 0 auto;
user-select: none;
}
.tbc_guest_warn_badge::before {
content: "i";
transform: translateY(-0.25px);
}
.tbc_guest_warn_badge_playerlist {
margin-left: 4px;
vertical-align: middle;
}
.cg_player_right {
display:flex;
align-items:center;
gap: 6px;
flex: 0 0 auto;
}
.cg_player_menu {
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
padding: 1px 4px;
border-radius: 4px;
}
.cg_player_row:hover .cg_player_menu { opacity: 1; }
.cg_player_menu:hover { background: rgba(0,0,0,0.22); }
.cg_color_bar {
display:flex;
align-items:center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
padding: 6px 6px;
border-radius: 8px;
background: rgba(0,0,0,0.18);
border: 1px solid rgba(255,255,255,0.10);
}
.cg_color_btn {
display:inline-flex;
align-items:center;
gap: 8px;
cursor: pointer;
user-select: none;
flex: 1 1 auto;
min-width: 0;
}
.cg_color_preview {
width: 18px;
height: 18px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.25);
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.25);
flex: 0 0 auto;
}
.cg_color_hex {
font-family: monospace;
font-size: 11px;
opacity: .88;
padding: 2px 6px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.18);
white-space: nowrap;
overflow:hidden;
text-overflow: ellipsis;
}
.cg_color_edithint {
font-size: 10px;
opacity: .7;
flex: 0 0 auto;
}
.cg_color_panel {
width: 260px;
}
.cg_color_panel_top {
display:flex;
align-items:center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.cg_color_panel_preview {
display:flex;
align-items:center;
gap: 8px;
min-width: 0;
}
.cg_color_panel_previewbox {
width: 22px;
height: 22px;
border-radius: 7px;
border: 1px solid rgba(255,255,255,0.25);
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.25);
flex: 0 0 auto;
}
.cg_color_panel_hex {
font-family: monospace;
font-size: 11px;
opacity: .9;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.18);
white-space: nowrap;
overflow:hidden;
text-overflow: ellipsis;
}
.cg_color_panel_body {
display:grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.cg_color_panel_section {
border: 1px solid rgba(255,255,255,0.10);
background: rgba(0,0,0,0.16);
border-radius: 8px;
padding: 8px;
}
.cg_color_panel_section_title {
font-size: 11px;
font-weight: 800;
opacity: .9;
margin-bottom: 6px;
}
.cg_color_swatches {
display:flex;
flex-wrap: wrap;
gap: 6px;
}
.cg_swatch {
width: 18px;
height: 18px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.25);
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.25);
cursor: pointer;
flex: 0 0 auto;
}
.cg_swatch:hover { transform: translateY(-1px); }
.cg_swatch.active {
outline: 2px solid rgba(255,255,255,0.35);
box-shadow: 0 0 0 2px rgba(121,85,248,0.35);
}
.cg_color_custom_row {
display:flex;
align-items:center;
justify-content: space-between;
gap: 8px;
}
.cg_color_custom_row input[type="color"]{
border: none;
padding: 0;
width: 34px;
height: 26px;
cursor: pointer;
background: transparent;
}
.cg_color_usebtn {
padding: 4px 8px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.06);
cursor:pointer;
font-size: 11px;
white-space: nowrap;
}
.cg_color_usebtn:hover { background: rgba(255,255,255,0.12); }
.cg_group.cg_group_add {
border: 1px dashed rgba(255,255,255,0.5);
background: transparent;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cg_group_add_inner { text-align: center; opacity: 0.9; }
.cg_group_add_plus { font-size: 24px; line-height: 1; margin-bottom: 4px; }
.cg_group_placeholder {
flex: 0 0 33%;
max-width: 33%;
border: 2px dashed rgba(255,255,255,0.4);
border-radius: 6px;
background: rgba(255,255,255,0.04);
height: 220px;
}
.cg_group_dragging {
animation: cg_rock 0.25s ease-in-out infinite alternate;
transform-origin: center center;
box-shadow: 0 8px 22px rgba(0,0,0,0.7);
cursor: grabbing !important;
}
@keyframes cg_rock {
0% { transform: rotate(-1.5deg) translateY(-3px); }
100% { transform: rotate(1.5deg) translateY(-3px); }
}
.mod_ctx_panel {
position: fixed;
background: rgba(25,25,25,0.96);
border-radius: 6px;
padding: 8px;
box-shadow: 0 6px 18px rgba(0,0,0,0.6);
z-index: 99999;
min-width: 180px;
font-size: 12px;
color: #fff;
}
.mod_ctx_title { font-weight: bold; margin-bottom: 6px; }
.mod_ctx_items {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
}
.mod_ctx_item {
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.mod_ctx_item:hover { background: rgba(255,255,255,0.08); }
.mod_ctx_input {
width: 100%;
box-sizing: border-box;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(0,0,0,0.2);
color: #fff;
padding: 6px 8px;
margin-top: 4px;
margin-bottom: 4px;
outline: none;
font-size: 12px;
}
.mod_ctx_buttons {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 6px;
}
.mod_ctx_button {
padding: 3px 8px;
border-radius: 6px;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.05);
font-size: 11px;
}
.mod_ctx_button:hover { background: rgba(255,255,255,0.12); }
.mod_ctx_button_primary {
border-color: rgba(121,85,248,0.8);
background: rgba(121,85,248,0.5);
}
.mod_ctx_error {
color: #ff6b6b;
font-size: 11px;
margin-top: 2px;
}
.cg_actions_row {
margin-top: 8px;
display:flex;
align-items:center;
justify-content:space-between;
gap: 10px;
flex-wrap: wrap;
}
.cg_action_btn {
display:inline-flex;
align-items:center;
gap: 6px;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.16);
cursor: pointer;
font-size: 11px;
opacity: .95;
}
.cg_action_btn:hover { background: rgba(255,255,255,0.08); }
.cg_selected_panel {
margin-top: 10px;
padding: 10px 10px 9px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.14);
}
.cg_selected_top {
display:flex;
align-items:baseline;
justify-content:space-between;
gap: 10px;
margin-bottom: 6px;
}
.cg_selected_name { font-weight: 800; font-size: 13px; }
.cg_selected_badge {
font-family: monospace;
font-size: 11px;
opacity: .9;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.18);
white-space:nowrap;
}
.cg_selected_meta { font-size: 11px; opacity: .85; margin-bottom: 8px; }
.cg_selected_controls { display:flex; align-items:center; gap: 8px; flex-wrap: wrap; }
.cg_selected_controls input[type="number"]{
width: 120px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.22);
color: #fff;
padding: 6px 8px;
font-size: 12px;
outline: none;
}
.cg_info {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.22);
background: rgba(0,0,0,0.18);
color: rgba(255,255,255,0.92);
font-size: 11px;
font-weight: 800;
line-height: 1;
cursor: help;
user-select: none;
flex: 0 0 auto;
}
.cg_info::before {
content: "i";
transform: translateY(-0.5px);
}
.cg_info_tip {
position: absolute;
left: 50%;
top: calc(100% + 6px);
transform: translateX(-50%);
min-width: 210px;
max-width: 260px;
padding: 7px 8px;
border-radius: 8px;
background: rgba(18, 22, 28, 0.98);
border: 1px solid rgba(255,255,255,0.14);
box-shadow: 0 10px 24px rgba(0,0,0,0.55);
font-size: 11px;
font-weight: 500;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
z-index: 999999;
}
.cg_info_tip b { font-weight: 800; }
.cg_info_tip small { opacity: 0.8; }
.cg_info:hover .cg_info_tip {
opacity: 1;
}
.cg_recolor_targets {
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.14);
}
.cg_recolor_targets_title {
font-size: 12px;
font-weight: 700;
margin-bottom: 6px;
opacity: 0.95;
}
.cg_recolor_targets_grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
}
.cg_recolor_targets_grid .cg_recolor_toggle {
min-height: 30px;
}
.cg_recolor_toggle.cg_recolor_toggle_full {
grid-column: 1 / span 2;
}
.cg_recolor_toggle .cg_recolor_toggle_label {
font-size: 11px;
opacity: 0.95;
}
`;
document.head.appendChild(style);
}
function updateGroupCardColors(card, color) {
const header = card.querySelector('.cg_group_header');
const light = hexToRgba(color, 0.18);
const medium = hexToRgba(color, 0.32);
if (header) header.style.background = `linear-gradient(90deg, ${light}, ${medium})`;
}
function sortPlayersForDisplay(players) {
return (players || [])
.slice()
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), undefined, { sensitivity: 'base' }));
}
function renderSelectedPlayerPanel() {
const host = $('cg_selected_panel_host');
if (!host) return;
host.textContent = '';
if (!selectedPlayer) return;
const g = colorGroups.find((x) => x.id === selectedPlayer.groupId);
if (!g) return;
const p = findPlayerInGroup(g.id, selectedPlayer.name, selectedPlayer.memberType || 'any');
if (!p) return;
const panel = document.createElement('div');
panel.className = 'cg_selected_panel';
const top = document.createElement('div');
top.className = 'cg_selected_top';
const nm = document.createElement('div');
nm.className = 'cg_selected_name';
nm.textContent = p.name;
top.appendChild(nm);
const meta = document.createElement('div');
meta.className = 'cg_selected_meta';
meta.textContent = `Group: ${g.name}`;
panel.appendChild(top);
panel.appendChild(meta);
host.appendChild(panel);
}
function renderGroupsUI() {
const list = $('cg_groups_list');
if (!list) return;
const lobbyInfo = getLobbyAccountInfoCached(500);
list.innerHTML = '';
colorGroups.forEach((group) => {
const card = document.createElement('div');
card.className = 'cg_group';
card.dataset.groupId = group.id;
const currentColor = String(group.color || '#ff0000');
card.innerHTML = `
<div class="cg_group_header">
<div class="cg_group_handle" title="Drag to reorder">⋮⋮</div>
<div class="cg_group_title" title="${group.name}">${group.name}</div>
<div class="cg_group_menu" title="Group options">⋮</div>
</div>
<div class="cg_group_meta">
<div class="cg_group_meta_line cg_group_meta_line_right">
${(group.players || []).length} players
</div>
</div>
<div class="cg_group_players"></div>
<!-- Color bar: click to open picker panel -->
<div class="cg_color_bar">
<div class="cg_color_btn" title="Click to choose a preset or custom colour">
<div class="cg_color_preview" style="background:${currentColor};"></div>
<div class="cg_color_hex">${currentColor.toLowerCase()}</div>
</div>
<div class="cg_color_edithint">Edit ▾</div>
</div>
`;
const playersContainer = card.querySelector('.cg_group_players');
const sortedPlayers = sortPlayersForDisplay(group.players);
sortedPlayers.forEach((player) => {
const row = document.createElement('div');
row.className = 'cg_player_row';
row.dataset.playerName = player.name;
row.dataset.playerMemberType = getGroupPlayerMemberType(player);
const isSelected =
selectedPlayer &&
selectedPlayer.groupId === group.id &&
memberIdentityMatches(selectedPlayer, player.name, getGroupPlayerMemberType(player));
if (isSelected) row.classList.add('selected');
row.innerHTML = `
<div class="cg_player_name" title="${player.name}">${player.name}</div>
<div class="cg_player_right">
<div class="cg_player_menu" title="Manage player">⋮</div>
</div>
`;
const nameHost = row.querySelector('.cg_player_name');
if (nameHost && shouldShowGuestWarningForGroupMember(player, lobbyInfo)) {
const warn = document.createElement('span');
warn.className = 'tbc_guest_warn_badge';
warn.title = 'Temporary guest member. Removed when guest/room/host state changes.';
warn.setAttribute('aria-label', 'Temporary guest member');
nameHost.appendChild(warn);
}
row.addEventListener('click', (e) => {
selectedPlayer = { groupId: group.id, name: player.name, memberType: getGroupPlayerMemberType(player) };
renderGroupsUI();
renderSelectedPlayerPanel();
});
playersContainer.appendChild(row);
});
updateGroupCardColors(card, group.color);
list.appendChild(card);
});
const addCard = document.createElement('div');
addCard.className = 'cg_group cg_group_add';
addCard.innerHTML = `
<div class="cg_group_add_inner">
<div class="cg_group_add_plus">+</div>
<div>Add new group</div>
</div>
`;
list.appendChild(addCard);
attachGroupEvents();
updateStorageHintUI();
}
function attachGroupEvents() {
const list = $('cg_groups_list');
if (!list) return;
const outer = $('cg_groups_outer');
const addCard = list.querySelector('.cg_group_add');
if (addCard) {
addCard.addEventListener('click', (e) => {
e.stopPropagation();
addGroup('New Group');
});
}
list.querySelectorAll('.cg_group').forEach((card) => {
const groupId = card.dataset.groupId;
if (!groupId) return;
const handle = card.querySelector('.cg_group_handle');
const titleEl = card.querySelector('.cg_group_title');
const menuEl = card.querySelector('.cg_group_menu');
const colorBtn = card.querySelector('.cg_color_btn');
const previewBox = card.querySelector('.cg_color_preview');
const hexChip = card.querySelector('.cg_color_hex');
function applyColorToCard(newHex) {
const val = String(newHex || '').trim();
if (!val) return;
setGroupColor(groupId, val);
if (hexChip) hexChip.textContent = val.toLowerCase();
if (previewBox) previewBox.style.background = val;
updateGroupCardColors(card, val);
}
if (colorBtn) {
colorBtn.addEventListener('click', (e) => {
e.stopPropagation();
const g = colorGroups.find((x) => x.id === groupId);
const current = String((g && g.color) || '#ff0000');
openPanel(colorBtn, (panel) => {
const presetSwatches = COLOR_PRESETS.map((p) => {
const active = p.color.toLowerCase() === current.toLowerCase();
return `<div class="cg_swatch${active ? ' active' : ''}" data-preset="${p.id}" title="${p.label}" style="background:${p.color};"></div>`;
}).join('');
panel.innerHTML = `
<div class="cg_color_panel">
<div class="cg_color_panel_top">
<div class="cg_color_panel_preview">
<div class="cg_color_panel_previewbox" style="background:${current};"></div>
<div class="cg_color_panel_hex">${current.toLowerCase()}</div>
</div>
</div>
<div class="cg_color_panel_body">
<div class="cg_color_panel_section">
<div class="cg_color_panel_section_title">Presets</div>
<div class="cg_color_swatches">
${presetSwatches}
</div>
</div>
<div class="cg_color_panel_section">
<div class="cg_color_panel_section_title">Custom</div>
<div class="cg_color_custom_row">
<input class="cg_custom_picker" type="color" value="${current}">
<div class="cg_color_usebtn">Use</div>
</div>
<div style="margin-top:6px;font-size:10px;opacity:.75;">
Pick a colour, then press “Use”.
</div>
</div>
</div>
</div>
`;
const preview = panel.querySelector('.cg_color_panel_previewbox');
const hexEl = panel.querySelector('.cg_color_panel_hex');
function setPreview(val) {
if (preview) preview.style.background = val;
if (hexEl) hexEl.textContent = val.toLowerCase();
}
panel.querySelectorAll('.cg_swatch').forEach((sw) => {
sw.addEventListener('click', () => {
const pid = sw.dataset.preset;
const p = COLOR_PRESETS.find((pp) => pp.id === pid);
if (!p) return;
panel.querySelectorAll('.cg_swatch').forEach((s2) => s2.classList.remove('active'));
sw.classList.add('active');
setPreview(p.color);
applyColorToCard(p.color);
});
});
const customPicker = panel.querySelector('.cg_custom_picker');
const useBtn = panel.querySelector('.cg_color_usebtn');
if (customPicker) {
customPicker.addEventListener('input', () => {
panel.querySelectorAll('.cg_swatch').forEach((s2) => s2.classList.remove('active'));
setPreview(customPicker.value);
});
}
if (useBtn) {
useBtn.addEventListener('click', () => {
if (!customPicker) return;
applyColorToCard(customPicker.value);
closePanel();
});
}
});
});
}
let dragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
let placeholder = null;
let startIndex = colorGroups.findIndex((g) => g.id === groupId);
function onMouseMove(e) {
if (!dragging) return;
card.style.left = `${e.clientX - dragOffsetX}px`;
card.style.top = `${e.clientY - dragOffsetY}px`;
}
function onMouseUp(e) {
if (!dragging) return;
dragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
const slots = colorGroups.length;
let targetIndex = startIndex;
if (slots > 0) {
const rect = (outer || list).getBoundingClientRect();
const scrollLeft = outer ? outer.scrollLeft : 0;
const totalWidth = outer ? outer.scrollWidth : rect.width;
let relX = (e.clientX - rect.left) + scrollLeft;
relX = Math.max(0, Math.min(relX, totalWidth - 1));
const slotWidth = totalWidth / slots;
targetIndex = Math.floor(relX / slotWidth);
targetIndex = Math.max(0, Math.min(targetIndex, slots - 1));
}
if (placeholder && placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
placeholder = null;
if (card.parentNode) card.parentNode.removeChild(card);
if (targetIndex !== startIndex) reorderGroupsToIndex(groupId, targetIndex);
else renderGroupsUI();
}
function startDrag(e) {
e.preventDefault();
e.stopPropagation();
if (dragging) return;
dragging = true;
startIndex = colorGroups.findIndex((g) => g.id === groupId);
const rect = card.getBoundingClientRect();
placeholder = document.createElement('div');
placeholder.className = 'cg_group_placeholder';
list.insertBefore(placeholder, card.nextSibling);
card.classList.add('cg_group_dragging');
card.style.position = 'absolute';
card.style.width = `${rect.width}px`;
card.style.height = `${rect.height}px`;
card.style.left = `${rect.left}px`;
card.style.top = `${rect.top}px`;
card.style.pointerEvents = 'none';
card.style.zIndex = '100000';
document.body.appendChild(card);
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
if (handle) handle.addEventListener('mousedown', startDrag);
if (titleEl) {
titleEl.addEventListener('dblclick', (e) => {
e.stopPropagation();
openPanel(titleEl, (panel) => {
const g = colorGroups.find((x) => x.id === groupId);
const currentName = g ? g.name : '';
panel.innerHTML = `
<div class="mod_ctx_title">Rename group</div>
<input class="mod_ctx_input" type="text" value="${currentName}">
<div class="mod_ctx_buttons">
<div class="mod_ctx_button mod_ctx_button_primary">Save</div>
<div class="mod_ctx_button">Cancel</div>
</div>
`;
const input = panel.querySelector('.mod_ctx_input');
const btnSave = panel.querySelector('.mod_ctx_button_primary');
const btnCancel = panel.querySelectorAll('.mod_ctx_button')[1];
btnSave.addEventListener('click', () => {
const value = input.value.trim();
if (value) renameGroup(groupId, value);
closePanel();
});
btnCancel.addEventListener('click', () => closePanel());
input.focus();
input.select();
});
});
}
if (menuEl) {
menuEl.addEventListener('click', (e) => {
e.stopPropagation();
openPanel(menuEl, (panel) => {
const g = colorGroups.find((x) => x.id === groupId);
panel.innerHTML = `
<div class="mod_ctx_title">Group: ${g ? g.name : ''}</div>
<div class="mod_ctx_items">
<div class="mod_ctx_item" data-action="add">Add player</div>
<div class="mod_ctx_item" data-action="rename">Rename group</div>
<div class="mod_ctx_item" data-action="delete" style="color:#ff6b6b;">Delete group</div>
</div>
`;
panel.querySelectorAll('.mod_ctx_item').forEach((item) => {
item.addEventListener('click', () => {
const action = item.dataset.action;
closePanel();
if (action === 'add') {
openPanel(menuEl, (panel2) => {
panel2.innerHTML = `
<div class="mod_ctx_title">Add player</div>
<input class="mod_ctx_input" type="text" placeholder="Player name">
<div class="mod_ctx_error" style="display:none;"></div>
<div class="mod_ctx_buttons">
<div class="mod_ctx_button mod_ctx_button_primary">Add</div>
<div class="mod_ctx_button">Cancel</div>
</div>
`;
const input = panel2.querySelector('.mod_ctx_input');
const errEl = panel2.querySelector('.mod_ctx_error');
const btnAdd = panel2.querySelector('.mod_ctx_button_primary');
const btnCancel = panel2.querySelectorAll('.mod_ctx_button')[1];
btnAdd.addEventListener('click', () => {
errEl.style.display = 'none';
const res = addPlayerToGroup(groupId, input.value);
if (!res.ok) {
errEl.textContent = res.error || 'Error.';
errEl.style.display = 'block';
} else {
closePanel();
}
});
btnCancel.addEventListener('click', () => closePanel());
input.focus();
});
}
if (action === 'rename') {
const g2 = colorGroups.find((x) => x.id === groupId);
openPanel(menuEl, (panel2) => {
panel2.innerHTML = `
<div class="mod_ctx_title">Rename group</div>
<input class="mod_ctx_input" type="text" value="${g2 ? g2.name : ''}">
<div class="mod_ctx_buttons">
<div class="mod_ctx_button mod_ctx_button_primary">Save</div>
<div class="mod_ctx_button">Cancel</div>
</div>
`;
const input = panel2.querySelector('.mod_ctx_input');
const btnSave = panel2.querySelector('.mod_ctx_button_primary');
const btnCancel = panel2.querySelectorAll('.mod_ctx_button')[1];
btnSave.addEventListener('click', () => {
const value = input.value.trim();
if (value) renameGroup(groupId, value);
closePanel();
});
btnCancel.addEventListener('click', () => closePanel());
input.focus();
input.select();
});
}
if (action === 'delete') {
openPanel(menuEl, (panel2) => {
panel2.innerHTML = `
<div class="mod_ctx_title">Delete group?</div>
<div style="font-size:11px;opacity:0.8;margin-top:2px;">
This will remove the group and all its player assignments.
</div>
<div class="mod_ctx_buttons">
<div class="mod_ctx_button mod_ctx_button_primary"
style="background:rgba(255,65,65,0.7);border-color:rgba(255,65,65,0.9);">
Delete
</div>
<div class="mod_ctx_button">Cancel</div>
</div>
`;
const btnDelete = panel2.querySelector('.mod_ctx_button_primary');
const btnCancel = panel2.querySelectorAll('.mod_ctx_button')[1];
btnDelete.addEventListener('click', () => {
deleteGroup(groupId);
closePanel();
});
btnCancel.addEventListener('click', () => closePanel());
});
}
});
});
});
});
}
card.querySelectorAll('.cg_player_row').forEach((row) => {
const pname = row.dataset.playerName;
const ptype = normalizeMemberType(row.dataset.playerMemberType || 'any');
const menu = row.querySelector('.cg_player_menu');
if (!pname) return;
if (!menu) return;
menu.addEventListener('click', (e) => {
e.stopPropagation();
openPanel(menu, (panel) => {
panel.innerHTML = `
<div class="mod_ctx_title">${pname}</div>
<div class="mod_ctx_items">
<div class="mod_ctx_item" data-action="move">Move to another group</div>
<div class="mod_ctx_item" data-action="remove" style="color:#ff6b6b;">Remove from this group</div>
</div>
`;
panel.querySelectorAll('.mod_ctx_item').forEach((item) => {
item.addEventListener('click', () => {
const action = item.dataset.action;
closePanel();
if (action === 'move') {
if (colorGroups.length < 2) return;
openPanel(menu, (panel2) => {
const itemsHtml = colorGroups
.filter((g) => g.id !== groupId)
.map((g) => `<div class="mod_ctx_item" data-target="${g.id}">${g.name}</div>`)
.join('');
panel2.innerHTML = `
<div class="mod_ctx_title">Move "${pname}" to:</div>
<div class="mod_ctx_error" style="display:none;"></div>
<div class="mod_ctx_items">
${itemsHtml || '<div style="font-size:11px;opacity:0.8;">No other groups.</div>'}
</div>
`;
const errEl = panel2.querySelector('.mod_ctx_error');
panel2.querySelectorAll('.mod_ctx_item').forEach((it2) => {
it2.addEventListener('click', () => {
const targetId = it2.dataset.target;
const res = movePlayerToGroup(groupId, targetId, pname, ptype);
if (!res.ok) {
errEl.textContent = res.error || 'Error.';
errEl.style.display = 'block';
} else {
closePanel();
}
});
});
});
}
if (action === 'remove') removePlayerFromGroup(groupId, pname, ptype);
});
});
});
});
});
});
}
let recolorModRegistered = false;
function initRecolorMod() {
if (recolorModRegistered) return;
const bonkMods = window.bonkMods;
if (!bonkMods) return;
recolorModRegistered = true;
ensureRecolorStyles();
window.recolorAPI = {
getColorForName,
getGroups: () => JSON.parse(JSON.stringify(colorGroups)),
};
bonkMods.registerMod({
id: 'recolor',
name: 'Re:Color',
version: '1.3.2',
author: 'SIoppy',
description: `
Colour groups for player names.
Sort and manage players by name inside each group.
`,
devHint: 'Exposes window.recolorAPI.getColorForName(name).',
});
bonkMods.registerCategory({
id: 'recolor_main',
label: 'Colour Groups',
order: 50,
});
bonkMods.addBlock({
id: 'recolor_groups',
modId: 'recolor',
categoryId: 'recolor_main',
title: 'Colour Groups',
order: 0,
render(container) {
container.innerHTML = `
<div class="mod_block_sub">
Create groups of player names and assign them a colour.
Players are listed alphabetically within each group.
</div>
<div id="recolor_storage_hint" style="margin-top:6px;font-size:11px;"></div>
<div id="cg_sync_locked_notice" style="display:none;margin-top:6px;font-size:11px;color:#b53030;font-weight:700;">
Shared groups sync is active. Colour Groups settings are locked until desync.
</div>
<div id="cg_lockable_root">
<div class="cg_recolor_targets">
<div class="cg_recolor_targets_title">Apply colours to</div>
<div class="cg_recolor_targets_grid">
<div class="tbc_toggle cg_recolor_toggle" id="cg_toggle_player_names"><div class="tbc_toggle_dot"></div><div class="cg_recolor_toggle_label">Player names (in-game)</div></div>
<div class="tbc_toggle cg_recolor_toggle" id="cg_toggle_winner_board"><div class="tbc_toggle_dot"></div><div class="cg_recolor_toggle_label">Winner board</div></div>
<div class="tbc_toggle cg_recolor_toggle" id="cg_toggle_lobby_chat_names"><div class="tbc_toggle_dot"></div><div class="cg_recolor_toggle_label">Lobby chat names</div></div>
<div class="tbc_toggle cg_recolor_toggle" id="cg_toggle_ingame_chat_names"><div class="tbc_toggle_dot"></div><div class="cg_recolor_toggle_label">In-game chat names</div></div>
<div class="tbc_toggle cg_recolor_toggle cg_recolor_toggle_full" id="cg_toggle_player_list_names"><div class="tbc_toggle_dot"></div><div class="cg_recolor_toggle_label">Player list names</div></div>
<div class="tbc_toggle cg_recolor_toggle cg_recolor_toggle_full" id="cg_toggle_player_list_backboard"><div class="tbc_toggle_dot"></div><div class="cg_recolor_toggle_label">Player list backboard</div></div>
</div>
</div>
<div id="cg_groups_outer">
<div id="cg_groups_list"></div>
</div>
<div id="cg_selected_panel_host"></div>
</div>
`;
renderGroupsUI();
renderSelectedPlayerPanel();
updateStorageHintUI();
const applyRecolorSyncLockUi = () => {
const lockable = $('cg_lockable_root');
const notice = $('cg_sync_locked_notice');
if (notice) notice.style.display = recolorUiSyncLocked ? '' : 'none';
if (lockable) {
lockable.style.opacity = recolorUiSyncLocked ? '0.5' : '1';
lockable.style.pointerEvents = recolorUiSyncLocked ? 'none' : '';
}
};
const toggleMap = [
['cg_toggle_player_names', 'playerNames'],
['cg_toggle_winner_board', 'winnerBoard'],
['cg_toggle_ingame_chat_names', 'ingameChatNames'],
['cg_toggle_lobby_chat_names', 'lobbyChatNames'],
['cg_toggle_player_list_names', 'playerListNames'],
['cg_toggle_player_list_backboard', 'playerListBackboard'],
];
const renderRecolorSettingsToggles = () => {
toggleMap.forEach(([id, key]) => {
const el = $(id);
if (!el) return;
el.classList.toggle('on', !!recolorDisplaySettings[key]);
});
};
toggleMap.forEach(([id, key]) => {
const el = $(id);
if (!el) return;
el.addEventListener('click', () => {
recolorDisplaySettings[key] = !recolorDisplaySettings[key];
saveRecolorDisplaySettings();
});
});
refreshRecolorSettingsUi = renderRecolorSettingsToggles;
renderRecolorSettingsToggles();
const isSelfHostForRecolorSyncLock = () => {
if (typeof isSelfLobbyHost === 'function' && isSelfLobbyHost()) return true;
if (typeof isSelfLobbyHostForRecolor === 'function' && isSelfLobbyHostForRecolor()) return true;
const selfNorm = normalizeName((($('pretty_top_name') || {}).textContent || ''));
if (!selfNorm) return false;
const hostNormMain = typeof getLobbyHostNameNorm === 'function' ? normalizeName(getLobbyHostNameNorm()) : '';
if (hostNormMain && selfNorm === hostNormMain) return true;
const hostNormFallback = normalizeName(getLobbyHostNameNormForRecolor());
return !!hostNormFallback && selfNorm === hostNormFallback;
};
const computeRecolorSyncLock = () => {
if (isSelfHostForRecolorSyncLock()) return false;
return !!window.tbcRoomGroupsSyncActive;
};
if (!recolorSyncLockListenerInstalled) {
recolorSyncLockListenerInstalled = true;
const refreshLock = () => {
recolorUiSyncLocked = computeRecolorSyncLock();
applyRecolorSyncLockUi();
};
window.addEventListener('tbcSharedSyncStateChanged', refreshLock);
window.addEventListener('tbcSharedGroupsChanged', refreshLock);
}
recolorUiSyncLocked = computeRecolorSyncLock();
applyRecolorSyncLockUi();
},
});
setTimeout(() => {
updateAccountStorageKey();
updateStorageHintUI();
}, 0);
}
if (window.bonkMods) initRecolorMod();
window.addEventListener('bonkModsReady', initRecolorMod);
const origFillText = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function fillText(text, x, y) {
if (!recolorDisplaySettings.playerNames) return origFillText.call(this, text, x, y);
if (!hasDisplayGroupsForNames()) return origFillText.call(this, text, x, y);
const color = getDisplayColorForName(String(text || ''));
if (color) {
const oldFill = this.fillStyle;
this.fillStyle = color;
origFillText.call(this, text, x, y);
this.fillStyle = oldFill;
return;
}
origFillText.call(this, text, x, y);
};
(() => {
const CHAT_STORAGE_PREFIX_V2 = 'bonk_tbc_chat_v1_';
const REPLAY_SYSTEM_COLOR = 'rgb(181, 48, 48)';
const DEFAULT_SYSTEM_STATUS_COLOR = '#317dd7';
const SYSTEM_COLOR_CATEGORIES = [
{ id: 'defaultSystem', label: 'Default system' },
{ id: 'portal', label: 'Portal messages' },
{ id: 'userJoin', label: 'User joined' },
{ id: 'userLeft', label: 'User left' },
{ id: 'kick', label: 'Kicked' },
{ id: 'ban', label: 'Banned' },
{ id: 'replay', label: 'Replay messages' },
{ id: 'hostTransfer', label: 'Host transfer' },
{ id: 'helpHint', label: 'Help hint / unknown cmd' },
{ id: 'customCommands', label: 'Custom commands' },
{ id: 'friend', label: 'Friend status' },
];
const SYSTEM_COLOR_DEFAULT_HEX = {
defaultSystem: '#317dd7',
portal: '#44c0ff',
userJoin: '#2e6f40',
userLeft: '#cd1c18',
kick: '#942222',
ban: '#4a0404',
replay: '#8c92ac',
hostTransfer: '#317dd7',
helpHint: '#317dd7',
customCommands: '#b357d6',
friend: '#4682b4',
};
const SYSTEM_COLOR_NEXT_FORMAT = { hex: 'rgb', rgb: 'hsv', hsv: 'hex' };
function createDefaultChatState() {
return {
hideGuests: false,
showSystemMessages: false,
useCustomSystemMessageColors: false,
ingameChatBackgrounds: false,
hideIngameOthersUntilFadeDelay: false,
blacklistUsers: [],
systemMessageColors: createDefaultSystemMessageColors(),
ingameChatLines: 4,
ingameFadeDelaySec: 8
};
}
function createDefaultSystemMessageColors() {
const out = {};
SYSTEM_COLOR_CATEGORIES.forEach((cat) => {
out[cat.id] = {
hex: String(SYSTEM_COLOR_DEFAULT_HEX[cat.id] || SYSTEM_COLOR_DEFAULT_HEX.defaultSystem).toLowerCase(),
format: 'hex',
};
});
return out;
}
let chatState = createDefaultChatState();
let chatStorageKey = null;
let lastChatStorageKey = undefined;
let lastChatIdentity = undefined;
let chatAccountObserver = null;
let refreshChatSettingsUi = null;
let ingameVisualRefreshQueued = false;
let ingameChatFocusGuardsInstalled = false;
let groupsPanelVisible = false;
let pointsPanelVisible = false;
let sharedHostGroupsSnapshot = [];
let roomGroupsSyncActive = false;
let groupsSyncChunksBySession = new Map();
let groupsSyncTask = null;
let groupsPanelAutoSyncTimer = null;
let groupsSyncLastSig = '';
let hostGroupsPanelSyncedRoomKey = '';
let groupsRefreshSignalNextAllowedAt = 0;
const GROUPS_SYNC_MAX_PAYLOAD_CHARS = 170;
const GROUPS_SYNC_CHUNK_CHARS = 220;
const GROUPS_SYNC_CHUNK_INTERVAL_MS = 7000;
const ROOM_GROUPS_CACHE_PREFIX = 'bonk_tbc_room_groups_v1_';
let lastRoomGroupsKey = '';
let lastSharedBridgeSig = '';
let lastSharedGroupsJoinNoticeKey = '';
let lastObservedLobbyHostNorm = '';
let liveGroupsSyncHostNorm = '';
let lastHostTransferDesyncSig = '';
let lastHostClosedRoomDesyncSig = '';
let groupsRelayBusy = false;
let pendingGroupsRelayTokenPull = '';
let roomRelayPollTimer = null;
let roomRelayPollInFlight = false;
let roomRelayPollPausedByRenderer = false;
let roomUiBothHiddenSince = 0;
let lastGroupsSyncEligibility = null;
let lastRendererVisibleForNonHostExitDesync = null;
let lastRendererVisibleForPointsSnapshotReset = null;
let pendingHostEventDesyncMessage = '';
const roomRelayLastAppliedStampByScope = new Map();
const GROUPS_SYNC_LIFECYCLE_STATUS_VISIBLE = false;
const groupsPanelPlacement = { left: 12, top: 120, width: 248, maxHeight: 560 };
const pointsPanelPlacement = { left: 270, top: 120, width: 300, maxHeight: 560 };
let groupsPanelPinnedByUser = false;
const groupsPanelDragState = { active: false, pointerId: null, offsetX: 0, offsetY: 0 };
let pointsPanelPinnedByUser = false;
const pointsPanelDragState = { active: false, pointerId: null, offsetX: 0, offsetY: 0 };
let pointsPanelLastRenderSig = '';
let pointsPanelCachedSourceEntries = [];
let lastTempGuestPruneAt = 0;
function setRoomGroupsSyncActive(active) {
const next = !!active;
if (roomGroupsSyncActive === next) return;
roomGroupsSyncActive = next;
window.tbcRoomGroupsSyncActive = next;
window.dispatchEvent(new CustomEvent('tbcSharedSyncStateChanged', { detail: { active: next } }));
}
function applySharedGroupsDesyncNow(statusMessage = '') {
pendingHostEventDesyncMessage = '';
sharedHostGroupsSnapshot = [];
groupsSyncChunksBySession = new Map();
liveGroupsSyncHostNorm = '';
setRoomGroupsSyncActive(false);
purgeTemporaryGuestGroupMembers({ onlyMissingInLobby: false });
syncSharedGroupsBridge();
resetWinnerBoardTransientState();
if (groupsPanelVisible) renderGroupsPanel();
if (statusMessage) addGroupsLifecycleStatus(statusMessage);
}
function queueOrApplySharedGroupsDesync(statusMessage = '') {
const rendererVisible = isElementActuallyVisible($('gamerenderer'));
if (rendererVisible) {
pendingHostEventDesyncMessage = String(statusMessage || '').trim();
return false;
}
applySharedGroupsDesyncNow(statusMessage);
return true;
}
function flushPendingSharedGroupsDesyncIfReady() {
const msg = String(pendingHostEventDesyncMessage || '').trim();
if (!msg) return false;
const rendererVisible = isElementActuallyVisible($('gamerenderer'));
if (rendererVisible) return false;
applySharedGroupsDesyncNow(msg);
return true;
}
function addGroupsLifecycleStatus(text, color = '#317dd7') {
if (!GROUPS_SYNC_LIFECYCLE_STATUS_VISIBLE) return;
addLocalChatStatus(text, color);
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function getLobbyHostNameNorm() {
const badges = Array.from(document.querySelectorAll('.newbonklobby_playerentry_host'));
for (const hostBadge of badges) {
const src = String(hostBadge.getAttribute('src') || hostBadge.src || '').toLowerCase();
const row = hostBadge.closest('.newbonklobby_playerentry');
if (!row) continue;
const nameEl = row.querySelector('.newbonklobby_playerentry_name');
if (!nameEl) continue;
const nameNorm = normalizeName(nameEl.textContent || '');
if (!nameNorm) continue;
if (src && /host_(?:[1-9]\d*)\.png/.test(src)) return nameNorm;
const cs = window.getComputedStyle(hostBadge);
const visible =
!!cs &&
cs.display !== 'none' &&
cs.visibility !== 'hidden' &&
cs.opacity !== '0' &&
(
(hostBadge.offsetWidth || 0) > 0 ||
(hostBadge.offsetHeight || 0) > 0 ||
(hostBadge.getClientRects ? hostBadge.getClientRects().length : 0) > 0
);
if (visible && src && src.indexOf('host_0.png') === -1) return nameNorm;
}
return '';
}
function isSelfLobbyHost() {
const selfNorm = getSelfNameNorm();
const hostNorm = getLobbyHostNameNorm();
return !!selfNorm && !!hostNorm && selfNorm === hostNorm;
}
function canHostEditSharedGroupsPanel() {
if (!isSelfLobbyHost()) return false;
const hostNorm = getLobbyHostNameNorm();
if (!hostNorm) return false;
return hostGroupsPanelSyncedRoomKey === `h:${hostNorm}`;
}
function markHostGroupsPanelSyncedForCurrentRoom() {
if (!isSelfLobbyHost()) return;
const hostNorm = getLobbyHostNameNorm();
if (!hostNorm) return;
hostGroupsPanelSyncedRoomKey = `h:${hostNorm}`;
}
function normalizeSharedGroupSnapshot(raw) {
if (!Array.isArray(raw)) return [];
return raw
.map((g) => {
if (!g || typeof g !== 'object') return null;
const name = String(g.name || 'Group').trim();
const color = String(g.color || getRandomPresetColor()).trim() || getRandomPresetColor();
const playersRaw = Array.isArray(g.players) ? g.players : [];
const players = playersRaw
.map((p) => {
if (typeof p === 'string') return { name: String(p || '').trim(), memberType: 'account' };
if (p && typeof p === 'object') {
const name = String(p.name || '').trim();
const memberType = normalizeMemberType(p.memberType || (p.tempGuest ? 'guest' : 'account'));
return { name, memberType: memberType === 'any' ? 'account' : memberType };
}
return null;
})
.filter((p) => p && p.name);
return { name, color, players };
})
.filter(Boolean);
}
function getLobbyRoomSyncKey() {
const hostNorm = getLobbyHostNameNorm();
const mapText = String((($('newbonklobby_maptext') || {}).textContent || '')).trim().toLowerCase();
const modeText = String((($('newbonklobby_modetext') || {}).textContent || '')).trim().toLowerCase();
const roundsText = String((($('newbonklobby_roundsinput') || {}).value || '')).trim().toLowerCase();
const teamsText = String((($('newbonklobby_teams_middletext') || {}).textContent || '')).trim().toLowerCase();
if (!hostNorm || !mapText || !modeText) return '';
return `h:${hostNorm}|m:${mapText}|mode:${modeText}|r:${roundsText}|t:${teamsText}`;
}
function syncSharedGroupsBridge() {
const safe = normalizeSharedGroupSnapshot(sharedHostGroupsSnapshot || []);
const prev = Array.isArray(window.tbcSharedGroupsSnapshot) ? window.tbcSharedGroupsSnapshot : [];
const mapByName = (groups) => {
const out = new Map();
(Array.isArray(groups) ? groups : []).forEach((g) => {
if (!g || typeof g !== 'object') return;
const color = String(g.color || '').trim();
const players = Array.isArray(g.players) ? g.players : [];
players.forEach((p) => {
const pName = typeof p === 'string' ? p : (p && p.name ? p.name : '');
const nm = normalizeName(pName);
const pType = getGroupPlayerMemberType(p);
const key = nm ? `${nm}|${getLookupTypeSuffix(pType)}` : '';
if (!key || out.has(key)) return;
out.set(key, color || '');
});
});
return out;
};
const prevMap = mapByName(prev);
const nextMap = mapByName(safe);
const changedPlayersSet = new Set();
const touched = new Set([...prevMap.keys(), ...nextMap.keys()]);
touched.forEach((key) => {
if ((prevMap.get(key) || '') !== (nextMap.get(key) || '')) {
const nm = String(key || '').split('|')[0] || '';
if (nm) changedPlayersSet.add(nm);
}
});
const changedPlayers = Array.from(changedPlayersSet);
const sig = JSON.stringify(
safe.map((g) => ({
name: g.name,
color: g.color,
players: (g.players || []).map((p) => [
String((p && p.name) || '').trim(),
getLookupTypeSuffix(getGroupPlayerMemberType(p)),
]),
}))
);
if (sig === lastSharedBridgeSig) return;
lastSharedBridgeSig = sig;
window.tbcSharedGroupsSnapshot = safe;
window.dispatchEvent(new CustomEvent('tbcSharedGroupsChanged', {
detail: { changedPlayers }
}));
}
function saveRoomGroupsCache(snapshot) {
const roomKey = getLobbyRoomSyncKey();
if (!roomKey) return;
try {
const payload = {
at: Date.now(),
groups: normalizeSharedGroupSnapshot(snapshot),
};
localStorage.setItem(ROOM_GROUPS_CACHE_PREFIX + roomKey, JSON.stringify(payload));
} catch (e) {
console.error('[TBC] Failed to save room groups cache', e);
}
}
function loadRoomGroupsCacheForCurrentRoom() {
const roomKey = getLobbyRoomSyncKey();
if (!roomKey) return null;
try {
const raw = localStorage.getItem(ROOM_GROUPS_CACHE_PREFIX + roomKey);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
return normalizeSharedGroupSnapshot(parsed.groups);
} catch (e) {
console.error('[TBC] Failed to load room groups cache', e);
return null;
}
}
function maybeLoadRoomGroupsCache() {
const prevHostNorm = lastObservedLobbyHostNorm;
const hostNorm = getLobbyHostNameNorm();
lastObservedLobbyHostNorm = hostNorm || '';
const rendererVisibleNow = isElementActuallyVisible($('gamerenderer'));
const lobbyVisibleNow = isElementActuallyVisible($('newbonklobby'));
const hostChanged = !!prevHostNorm && !!hostNorm && prevHostNorm !== hostNorm;
if (hostChanged && (roomGroupsSyncActive || sharedHostGroupsSnapshot.length > 0)) {
queueOrApplySharedGroupsDesync('[TBC] Host changed. Shared groups desynced until the new host syncs.');
}
const roomKey = getLobbyRoomSyncKey();
if (roomKey === lastRoomGroupsKey) return;
lastRoomGroupsKey = roomKey;
resetWinnerBoardTransientState();
if (!roomKey) {
return;
}
if (isSelfLobbyHost()) {
if (
roomGroupsSyncActive &&
!!liveGroupsSyncHostNorm &&
!!hostNorm &&
liveGroupsSyncHostNorm !== hostNorm
) {
queueOrApplySharedGroupsDesync('[TBC] Host changed. Shared groups desynced until the new host syncs.');
} else if (!roomGroupsSyncActive) {
sharedHostGroupsSnapshot = [];
liveGroupsSyncHostNorm = '';
syncSharedGroupsBridge();
}
if (groupsPanelVisible) renderGroupsPanel();
return;
}
const prevSnapshot = normalizeSharedGroupSnapshot(sharedHostGroupsSnapshot);
const cached = loadRoomGroupsCacheForCurrentRoom();
const deferSnapshotSwapDuringActiveRound =
rendererVisibleNow &&
!lobbyVisibleNow &&
roomGroupsSyncActive &&
prevSnapshot.length > 0 &&
(!cached || !cached.length);
if (deferSnapshotSwapDuringActiveRound) {
sharedHostGroupsSnapshot = prevSnapshot;
setRoomGroupsSyncActive(true);
syncSharedGroupsBridge();
if (groupsPanelVisible) renderGroupsPanel();
return;
}
sharedHostGroupsSnapshot = cached || [];
const hostForRoom = getLobbyHostNameNorm();
const hostUnknown = !hostForRoom;
const hostMatchesLiveSync = !!liveGroupsSyncHostNorm && !!hostForRoom && liveGroupsSyncHostNorm === hostForRoom;
const canSafelyKeepPrevSnapshot =
(!cached || !cached.length) &&
roomGroupsSyncActive &&
prevSnapshot.length > 0 &&
hostMatchesLiveSync;
if (canSafelyKeepPrevSnapshot) {
sharedHostGroupsSnapshot = prevSnapshot;
saveRoomGroupsCache(sharedHostGroupsSnapshot);
setRoomGroupsSyncActive(true);
syncSharedGroupsBridge();
if (groupsPanelVisible) renderGroupsPanel();
return;
}
const keepLiveSync =
sharedHostGroupsSnapshot.length > 0 &&
hostMatchesLiveSync;
setRoomGroupsSyncActive(keepLiveSync);
syncSharedGroupsBridge();
if (sharedHostGroupsSnapshot.length && roomKey !== lastSharedGroupsJoinNoticeKey) {
lastSharedGroupsJoinNoticeKey = roomKey;
addGroupsLifecycleStatus('Room has synced groups...using host color groups');
}
if (groupsPanelVisible) renderGroupsPanel();
}
function maybeDesyncOnHostClosedRoomStatus(statusText) {
const raw = String(statusText || '').replace(/\s+/g, ' ').trim();
if (!raw) return;
if (!/^\*\s+.+\s+has left the game and closed the room\.?$/i.test(raw)) return;
const sig = normalizeName(raw);
if (!sig || sig === lastHostClosedRoomDesyncSig) return;
lastHostClosedRoomDesyncSig = sig;
purgeTemporaryGuestGroupMembers({ onlyMissingInLobby: false });
queueOrApplySharedGroupsDesync('[TBC] Host closed the room. Shared groups desynced.');
}
function getSharedGroupsSnapshotSig(groups) {
const snapshot = normalizeSharedGroupSnapshot(groups);
return JSON.stringify(snapshot.map((g) => [
g.name,
g.color,
(g.players || []).map((p) => [
String((p && p.name) || '').trim(),
getLookupTypeSuffix(getGroupPlayerMemberType(p)),
]),
]));
}
function buildCompactFullSyncEnvelope(groups) {
const snapshot = normalizeSharedGroupSnapshot(groups);
return {
m: 'f',
g: snapshot.map((grp) => [
String(grp.name || ''),
String(grp.color || ''),
(grp.players || [])
.map((p) => {
const n = String((p && p.name) || '').trim();
if (!n) return null;
const t = getLookupTypeSuffix(getGroupPlayerMemberType(p));
return [n, t];
})
.filter(Boolean),
]),
};
}
function buildDeltaSyncEnvelope(baseGroups, nextGroups) {
const base = normalizeSharedGroupSnapshot(baseGroups);
const next = normalizeSharedGroupSnapshot(nextGroups);
const baseStructSig = JSON.stringify(base.map((g) => [g.name, g.color]));
const nextStructSig = JSON.stringify(next.map((g) => [g.name, g.color]));
if (baseStructSig !== nextStructSig) return null;
const makePlayerMap = (groups) => {
const out = new Map();
groups.forEach((g, idx) => {
(g.players || []).forEach((p) => {
const displayName = String((p && p.name) || '').trim();
const norm = normalizeName(displayName);
const type = getLookupTypeSuffix(getGroupPlayerMemberType(p));
const key = norm ? `${norm}|${type}` : '';
if (!key || out.has(key)) return;
out.set(key, { name: displayName, type, idx });
});
});
return out;
};
const prevMap = makePlayerMap(base);
const nextMap = makePlayerMap(next);
const set = [];
const del = [];
nextMap.forEach((info, key) => {
const prev = prevMap.get(key);
if (!prev || prev.idx !== info.idx) set.push([info.name, info.type, info.idx]);
});
prevMap.forEach((info, key) => {
if (!nextMap.has(key)) del.push([info.name, info.type]);
});
if (!set.length && !del.length) return null;
return { m: 'd', s: nextStructSig, set, del };
}
function encodeSyncEnvelopeToB64(envelope) {
const json = JSON.stringify(envelope || {});
return btoa(unescape(encodeURIComponent(json)));
}
function decodeSyncEnvelopeFromB64(b64) {
const json = decodeURIComponent(escape(atob(String(b64 || ''))));
return JSON.parse(json);
}
function decodeCompactFullGroups(rows) {
if (!Array.isArray(rows)) return [];
return normalizeSharedGroupSnapshot(
rows.map((row) => {
const r = Array.isArray(row) ? row : [];
const name = String(r[0] || '').trim() || 'Group';
const color = String(r[1] || '').trim() || getRandomPresetColor();
const players = Array.isArray(r[2]) ? r[2] : [];
return {
name,
color,
players: players
.map((n) => {
if (Array.isArray(n)) {
const nm = String(n[0] || '').trim();
const mt = normalizeMemberType(n[1] || 'account');
if (!nm) return null;
return { name: nm, memberType: mt === 'guest' ? 'guest' : 'account', tempGuest: mt === 'guest' };
}
const nm = String(n || '').trim();
if (!nm) return null;
return { name: nm, memberType: 'account' };
})
.filter((p) => p && p.name),
};
})
);
}
function applyDeltaSyncEnvelope(baseGroups, envelope) {
const base = normalizeSharedGroupSnapshot(baseGroups);
const delta = envelope && typeof envelope === 'object' ? envelope : null;
if (!delta || delta.m !== 'd') return null;
const expectedSig = JSON.stringify(base.map((g) => [g.name, g.color]));
if (String(delta.s || '') !== expectedSig) return null;
const out = normalizeSharedGroupSnapshot(base);
const removeByNormAndType = (normName, memberType) => {
out.forEach((g) => {
g.players = (g.players || []).filter((p) => {
const nm = normalizeName((p && p.name) || '');
const mt = getLookupTypeSuffix(getGroupPlayerMemberType(p));
if (nm !== normName) return true;
if (memberType === 'any') return false;
return mt !== memberType;
});
});
};
(Array.isArray(delta.del) ? delta.del : []).forEach((n) => {
const pair = Array.isArray(n) ? n : [n, 'account'];
const norm = normalizeName(String(pair[0] || ''));
const memberType = getLookupTypeSuffix(normalizeMemberType(pair[1] || 'account'));
if (!norm) return;
removeByNormAndType(norm, memberType);
});
(Array.isArray(delta.set) ? delta.set : []).forEach((pair) => {
const row = Array.isArray(pair) ? pair : [];
const displayName = String(row[0] || '').trim();
const memberType = getLookupTypeSuffix(normalizeMemberType(row.length >= 3 ? row[1] : 'account'));
const norm = normalizeName(displayName);
const targetIdx = Math.floor(Number(row.length >= 3 ? row[2] : row[1]));
if (!norm || !Number.isFinite(targetIdx) || targetIdx < 0 || targetIdx >= out.length) return;
removeByNormAndType(norm, memberType);
const target = out[targetIdx];
if (!target) return;
target.players = Array.isArray(target.players) ? target.players : [];
target.players.push({ name: displayName, memberType: memberType === 'guest' ? 'guest' : 'account', tempGuest: memberType === 'guest' });
});
return normalizeSharedGroupSnapshot(out);
}
function encodeSharedGroupsPayload(groups) {
return encodeSyncEnvelopeToB64(buildCompactFullSyncEnvelope(groups));
}
function decodeSharedGroupsPayload(b64, baseSnapshot = null) {
const parsed = decodeSyncEnvelopeFromB64(b64);
if (Array.isArray(parsed)) {
return normalizeSharedGroupSnapshot(parsed);
}
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid shared-groups payload');
}
if (parsed.m === 'f') {
return decodeCompactFullGroups(parsed.g);
}
if (parsed.m === 'd') {
const applied = applyDeltaSyncEnvelope(baseSnapshot || [], parsed);
if (applied) return applied;
throw new Error('Delta payload cannot be applied to current base');
}
throw new Error('Unknown shared-groups payload mode');
}
const GROUPS_RELAY_GH_GISTS_API = 'https://api.github.com/gists';
const GROUPS_RELAY_VERSION = 1;
const GROUPS_RELAY_SHARED_GH_TOKEN = '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';
const GROUPS_RELAY_FILE_NAME = `tbc_groups_relay_v${GROUPS_RELAY_VERSION}.json`;
const GROUPS_RELAY_META_FILE_NAME = `tbc_groups_relay_meta_v${GROUPS_RELAY_VERSION}.json`;
const GROUPS_RELAY_SCOPE_DESC_PREFIX = 'TBC Groups Relay Scope ';
function getRoomRelayScopeKey() {
const roomKey = String(getLobbyRoomSyncKey() || '').trim();
if (!roomKey) return '';
let h = 0x811c9dc5;
for (let i = 0; i < roomKey.length; i += 1) {
h ^= roomKey.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return `rk_${(h >>> 0).toString(16).padStart(8, '0')}`;
}
function relayHttpText(method, url, body = null, headersOverride = undefined) {
return new Promise((resolve, reject) => {
const requestHeaders = body == null
? undefined
: (headersOverride === undefined ? { 'Content-Type': 'text/plain; charset=utf-8' } : headersOverride);
const gmReq =
(typeof GM_xmlhttpRequest === 'function' && GM_xmlhttpRequest) ||
(typeof GM !== 'undefined' && GM && typeof GM.xmlHttpRequest === 'function' ? GM.xmlHttpRequest.bind(GM) : null);
if (gmReq) {
gmReq({
method: String(method || 'GET').toUpperCase(),
url: String(url || ''),
anonymous: true,
responseType: 'text',
headers: requestHeaders,
data: body == null ? undefined : String(body),
onload: (res) => {
const status = Number(res && res.status);
if (status >= 200 && status < 300) resolve(String((res && res.responseText) || ''));
else {
const snippet = String((res && res.responseText) || '').replace(/\s+/g, ' ').trim().slice(0, 160);
reject(new Error(`HTTP ${Number.isFinite(status) ? status : 'ERR'}${snippet ? ` - ${snippet}` : ''}`));
}
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Request timeout')),
});
return;
}
fetch(String(url || ''), {
method: String(method || 'GET').toUpperCase(),
headers: requestHeaders,
body: body == null ? undefined : String(body),
})
.then((res) => {
return res.text().then((txt) => {
if (!res.ok) {
const snippet = String(txt || '').replace(/\s+/g, ' ').trim().slice(0, 160);
throw new Error(`HTTP ${res.status}${snippet ? ` - ${snippet}` : ''}`);
}
return txt;
});
})
.then((txt) => resolve(String(txt || '')))
.catch((e) => reject(e));
});
}
function bytesToBase64Url(bytes) {
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
let bin = '';
for (let i = 0; i < arr.length; i += 1) bin += String.fromCharCode(arr[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToBytes(text) {
const raw = String(text || '').replace(/-/g, '+').replace(/_/g, '/');
const pad = raw.length % 4;
const b64 = raw + (pad ? '='.repeat(4 - pad) : '');
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i) & 0xff;
return out;
}
function parseGroupsRelayToken(input) {
const raw = String(input || '').trim();
if (!raw) return null;
const fromGistUrl = raw.match(/gist\.github\.com\/[^/]+\/([A-Za-z0-9]+)(?:[#:]([A-Za-z0-9_-]+))?/i);
if (fromGistUrl) {
const id = String(fromGistUrl[1] || '').trim();
const key = String(fromGistUrl[2] || '').trim();
if (!id || !key) return null;
return { provider: 'gh', id, key };
}
const plain = raw.match(/^([A-Za-z0-9_.-]+):([A-Za-z0-9_-]+)$/);
if (plain) {
const left = String(plain[1] || '').trim();
const key = String(plain[2] || '').trim();
if (!left || !key) return null;
const dotted = left.match(/^([a-z]{2,4})\.(.+)$/i);
if (dotted) {
const provider = String(dotted[1] || '').toLowerCase();
const id = String(dotted[2] || '').trim();
if (!id) return null;
return { provider, id, key };
}
return { provider: 'gh', id: left, key };
}
return null;
}
function getGroupsRelayGithubToken() {
const sharedTok = String(GROUPS_RELAY_SHARED_GH_TOKEN || '').trim();
return sharedTok || null;
}
function maybeDesyncOnHostTransferStatus(statusText) {
const raw = String(statusText || '').replace(/\s+/g, ' ').trim();
if (!raw) return;
const transferA = /^\*\s+.+\s+has left the game and\s+.+\s+is now the game host\.?$/i;
const transferB = /^\*\s+.+\s+has given host privileges to\s+.+,\s*who is now the game host\.?$/i;
const transferC = /^\*\s+you are now the host of this game\.?$/i;
const transferD = /^\*\s+you are now the game host\.?$/i;
if (!transferA.test(raw) && !transferB.test(raw) && !transferC.test(raw) && !transferD.test(raw)) return;
const sig = normalizeName(raw);
if (!sig || sig === lastHostTransferDesyncSig) return;
lastHostTransferDesyncSig = sig;
purgeTemporaryGuestGroupMembers({ onlyMissingInLobby: false });
queueOrApplySharedGroupsDesync('[TBC] Host changed. Shared groups desynced until the new host syncs.');
}
async function uploadGroupsRelayViaGithub(relayBody, keyToken, scopeKey = '') {
const ghToken = getGroupsRelayGithubToken();
if (!ghToken) throw new Error('GitHub relay token not configured.');
const scope = String(scopeKey || '').trim();
const description = scope
? `${GROUPS_RELAY_SCOPE_DESC_PREFIX}${scope} @ ${Date.now()}`
: 'TBC Groups Relay (encrypted)';
const meta = JSON.stringify({
v: GROUPS_RELAY_VERSION,
scope,
key: String(keyToken || ''),
at: Date.now(),
});
const payload = JSON.stringify({
description,
public: false,
files: {
[GROUPS_RELAY_FILE_NAME]: { content: String(relayBody || '') },
[GROUPS_RELAY_META_FILE_NAME]: { content: meta },
},
});
const resText = await relayHttpText('POST', GROUPS_RELAY_GH_GISTS_API, payload, {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/vnd.github+json',
Authorization: `token ${ghToken}`,
'X-GitHub-Api-Version': '2022-11-28',
});
let parsed = null;
try { parsed = JSON.parse(String(resText || '{}')); } catch {}
const gistId = String((parsed && parsed.id) || '').trim();
if (!gistId) throw new Error('GitHub gist response missing id.');
return `gh.${gistId}:${keyToken}`;
}
async function encryptGroupsRelayPayload(snapshot) {
if (!window.crypto || !crypto.subtle || typeof TextEncoder === 'undefined') {
throw new Error('WebCrypto is unavailable in this browser.');
}
const env = {
v: GROUPS_RELAY_VERSION,
p: buildCompactFullSyncEnvelope(snapshot),
t: Date.now(),
};
const plain = new TextEncoder().encode(JSON.stringify(env));
const keyBytes = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain);
const cipher = new Uint8Array(cipherBuf);
return {
relayBody: JSON.stringify({
v: GROUPS_RELAY_VERSION,
a: 'A256GCM',
iv: bytesToBase64Url(iv),
ct: bytesToBase64Url(cipher),
}),
keyToken: bytesToBase64Url(keyBytes),
};
}
async function decryptGroupsRelayPayload(relayText, keyToken) {
if (!window.crypto || !crypto.subtle || typeof TextEncoder === 'undefined' || typeof TextDecoder === 'undefined') {
throw new Error('WebCrypto is unavailable in this browser.');
}
const parsed = JSON.parse(String(relayText || '{}'));
if (!parsed || parsed.a !== 'A256GCM' || !parsed.iv || !parsed.ct) {
throw new Error('Invalid relay payload.');
}
const keyBytes = base64UrlToBytes(keyToken);
const iv = base64UrlToBytes(parsed.iv);
const ct = base64UrlToBytes(parsed.ct);
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']);
const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
const plainText = new TextDecoder().decode(new Uint8Array(plainBuf));
const env = JSON.parse(plainText);
if (!env || Number(env.v) !== GROUPS_RELAY_VERSION || !env.p) {
throw new Error('Relay payload version mismatch.');
}
if (env.p.m === 'f') return decodeCompactFullGroups(env.p.g);
throw new Error('Unsupported relay payload mode.');
}
function broadcastGroupsRelayTokenFromHost(tokenText) {
if (!isSelfLobbyHost()) return false;
const token = String(tokenText || '').trim();
if (!token) return false;
return sendLobbyChatMessage(buildGroupsSyncTransportMessage(`[TBCGRELAY|${token}]`));
}
function schedulePendingRelayTokenPull() {
if (groupsRelayBusy) return;
const token = String(pendingGroupsRelayTokenPull || '').trim();
if (!token) return;
pendingGroupsRelayTokenPull = '';
setTimeout(() => {
pullGroupsRelaySnapshot(token).catch(() => {});
}, 75);
}
async function uploadGroupsRelaySnapshot(options = null) {
const announceRelay = !!(options && options.announceRelay);
const silent = !!(options && options.silent);
const status = (text, color = '#317dd7') => {
if (silent) return;
addLocalChatStatus(text, color);
};
if (groupsRelayBusy) {
status('[TBC] Relay is already in progress. Please wait.', 'rgb(181, 48, 48)');
return false;
}
groupsRelayBusy = true;
let succeeded = false;
try {
if (!isSelfLobbyHost()) {
status('[TBC] Only the room host can push a groups relay.', 'rgb(181, 48, 48)');
return false;
}
if (!canRunGroupsSyncNow()) {
addSyncDisabledStatusNotice({ silent });
return false;
}
if (!getGroupsRelayGithubToken()) {
const ok = broadcastSharedGroupsFromHost(true, { silent: true });
if (ok) {
markHostGroupsPanelSyncedForCurrentRoom();
if (groupsSyncTask) status('[TBC] Groups panel sync started (chat fallback).');
else status('[TBC] Groups panel refresh sent (chat fallback).');
} else {
status('[TBC] Chat fallback sync failed.', 'rgb(181, 48, 48)');
}
succeeded = !!ok;
return succeeded;
}
const snapshot = normalizeSharedGroupSnapshot(colorGroups);
const { relayBody, keyToken } = await encryptGroupsRelayPayload(snapshot);
const relayScope = getRoomRelayScopeKey();
const token = await uploadGroupsRelayViaGithub(relayBody, keyToken, relayScope);
status('[TBC] Relay uploaded via github gist.');
status('[TBC] Relay uploaded.');
let announced = true;
if (announceRelay) {
announced = broadcastGroupsRelayTokenFromHost(token);
if (announced) status('[TBC] Relay token broadcasted. Clients should auto-pull.');
else status('[TBC] Relay uploaded, but relay token broadcast failed.', 'rgb(181, 48, 48)');
}
succeeded = !announceRelay || !!announced;
if (succeeded) {
markHostGroupsPanelSyncedForCurrentRoom();
sharedHostGroupsSnapshot = normalizeSharedGroupSnapshot(snapshot);
saveRoomGroupsCache(sharedHostGroupsSnapshot);
liveGroupsSyncHostNorm = getLobbyHostNameNorm() || '';
setRoomGroupsSyncActive(true);
syncSharedGroupsBridge();
if (groupsPanelVisible) renderGroupsPanel();
}
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(String(token));
status('[TBC] Relay token copied to clipboard.');
}
} catch {}
} catch (e) {
console.error('[TBC] Relay upload failed', e);
status(`[TBC] Relay upload failed: ${e && e.message ? e.message : 'Unknown error'}`, 'rgb(181, 48, 48)');
succeeded = false;
} finally {
groupsRelayBusy = false;
schedulePendingRelayTokenPull();
}
return succeeded;
}
async function pullGroupsRelaySnapshot(tokenText) {
const requestedToken = String(tokenText || '').trim();
if (groupsRelayBusy) {
if (requestedToken) pendingGroupsRelayTokenPull = requestedToken;
return;
}
groupsRelayBusy = true;
try {
const token = parseGroupsRelayToken(requestedToken);
if (!token) {
addLocalChatStatus('[TBC] Invalid relay token.', 'rgb(181, 48, 48)');
return;
}
const provider = String(token.provider || 'gh').toLowerCase();
if (provider !== 'gh') throw new Error('Only GitHub relay tokens are supported now.');
const gistUrl = `${GROUPS_RELAY_GH_GISTS_API}/${token.id}`;
const pullHeaders = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};
const ghToken = getGroupsRelayGithubToken();
if (ghToken) pullHeaders.Authorization = `token ${ghToken}`;
const raw = String(await relayHttpText('GET', gistUrl, null, pullHeaders) || '');
let parsed = null;
try { parsed = JSON.parse(raw); } catch {}
const files = parsed && parsed.files && typeof parsed.files === 'object' ? parsed.files : null;
if (!files) throw new Error('GitHub gist files missing.');
const relayFile =
files[GROUPS_RELAY_FILE_NAME] ||
Object.values(files).find((f) => f && typeof f === 'object' && typeof f.content === 'string' && /relay/i.test(String(f.filename || '')));
const relayText = String((relayFile && relayFile.content) || '');
if (!relayText) throw new Error('GitHub gist content is empty.');
const metaFile =
files[GROUPS_RELAY_META_FILE_NAME] ||
Object.values(files).find((f) => f && typeof f === 'object' && typeof f.content === 'string' && /meta/i.test(String(f.filename || '')));
let metaKey = '';
try {
const parsedMeta = metaFile && typeof metaFile.content === 'string'
? JSON.parse(metaFile.content)
: null;
metaKey = String((parsedMeta && parsedMeta.key) || '').trim();
} catch {}
const useKey = String(token.key || metaKey || '').trim();
if (!useKey) throw new Error('Relay decryption key missing.');
const decoded = await decryptGroupsRelayPayload(relayText, useKey);
sharedHostGroupsSnapshot = normalizeSharedGroupSnapshot(decoded);
saveRoomGroupsCache(sharedHostGroupsSnapshot);
liveGroupsSyncHostNorm = getLobbyHostNameNorm() || '';
setRoomGroupsSyncActive(true);
syncSharedGroupsBridge();
if (groupsPanelVisible) renderGroupsPanel();
addGroupsLifecycleStatus('Room has synced groups...using host color groups');
} catch (e) {
console.error('[TBC] Relay pull failed', e);
addLocalChatStatus(`[TBC] Relay pull failed: ${e && e.message ? e.message : 'Unknown error'}`, 'rgb(181, 48, 48)');
} finally {
groupsRelayBusy = false;
schedulePendingRelayTokenPull();
}
}
async function pollRoomRelaySnapshotFallback() {
if (roomRelayPollInFlight || groupsRelayBusy) return;
const rendererVisible = isElementActuallyVisible($('gamerenderer'));
const lobbyVisible = isElementActuallyVisible($('newbonklobby'));
if (rendererVisible) {
roomRelayPollPausedByRenderer = true;
return;
}
if (!lobbyVisible) return;
if (roomRelayPollPausedByRenderer) roomRelayPollPausedByRenderer = false;
if (document.hidden) return;
if (isSelfLobbyHost()) return;
const scopeKey = getRoomRelayScopeKey();
if (!scopeKey) return;
const ghToken = getGroupsRelayGithubToken();
if (!ghToken) return;
roomRelayPollInFlight = true;
try {
const headers = {
Accept: 'application/vnd.github+json',
Authorization: `token ${ghToken}`,
'X-GitHub-Api-Version': '2022-11-28',
};
const listRaw = String(await relayHttpText('GET', `${GROUPS_RELAY_GH_GISTS_API}?per_page=30`, null, headers) || '[]');
let gists = [];
try { gists = JSON.parse(listRaw); } catch {}
if (!Array.isArray(gists) || !gists.length) return;
const descNeedle = `${GROUPS_RELAY_SCOPE_DESC_PREFIX}${scopeKey}`;
const target = gists.find((g) => {
const d = String((g && g.description) || '');
return d.indexOf(descNeedle) === 0;
});
if (!target || !target.id) return;
const stamp = `${String(target.id || '')}|${String(target.updated_at || '')}`;
if (roomRelayLastAppliedStampByScope.get(scopeKey) === stamp) return;
const gistRaw = String(await relayHttpText('GET', `${GROUPS_RELAY_GH_GISTS_API}/${target.id}`, null, headers) || '');
let gist = null;
try { gist = JSON.parse(gistRaw); } catch {}
const files = gist && gist.files && typeof gist.files === 'object' ? gist.files : null;
if (!files) return;
const relayFile =
files[GROUPS_RELAY_FILE_NAME] ||
Object.values(files).find((f) => f && typeof f === 'object' && typeof f.content === 'string' && /relay/i.test(String(f.filename || '')));
const metaFile =
files[GROUPS_RELAY_META_FILE_NAME] ||
Object.values(files).find((f) => f && typeof f === 'object' && typeof f.content === 'string' && /meta/i.test(String(f.filename || '')));
const relayText = String((relayFile && relayFile.content) || '');
if (!relayText) return;
let relayMeta = null;
try {
relayMeta = metaFile && typeof metaFile.content === 'string' ? JSON.parse(metaFile.content) : null;
} catch {
relayMeta = null;
}
const metaScope = String((relayMeta && relayMeta.scope) || '').trim();
if (metaScope && metaScope !== scopeKey) return;
const key = String((relayMeta && relayMeta.key) || '').trim();
if (!key) return;
const decoded = await decryptGroupsRelayPayload(relayText, key);
const lobbyVisibleNow = isElementActuallyVisible($('newbonklobby'));
const rendererVisibleNow = isElementActuallyVisible($('gamerenderer'));
if (!lobbyVisibleNow || rendererVisibleNow) return;
if (getRoomRelayScopeKey() !== scopeKey) return;
sharedHostGroupsSnapshot = normalizeSharedGroupSnapshot(decoded);
saveRoomGroupsCache(sharedHostGroupsSnapshot);
liveGroupsSyncHostNorm = getLobbyHostNameNorm() || '';
setRoomGroupsSyncActive(true);
syncSharedGroupsBridge();
if (groupsPanelVisible) renderGroupsPanel();
roomRelayLastAppliedStampByScope.set(scopeKey, stamp);
} catch (e) {
console.error('[TBC] Relay fallback poll failed', e);
} finally {
roomRelayPollInFlight = false;
}
}
function setupRoomRelayFallbackPolling() {
if (roomRelayPollTimer) {
clearInterval(roomRelayPollTimer);
roomRelayPollTimer = null;
}
}
function sendLobbyChatMessage(text) {
const input =
$('newbonklobby_chat_input') ||
document.querySelector('#newbonklobby_chatbox input[type="text"], #newbonklobby_chatbox textarea');
if (!(input instanceof HTMLInputElement) && !(input instanceof HTMLTextAreaElement)) return false;
const rawText = String(text || '');
const previousValue = String(input.value || '');
const isTransportPayload =
/\[TBCG(?:REF|CLR|RELAY)/i.test(rawText) ||
rawText.indexOf('\u2063\u2064') !== -1 ||
rawText.indexOf('\u2063') !== -1 ||
rawText.indexOf('\u2064') !== -1;
input.focus();
input.value = rawText;
input.dispatchEvent(new Event('input', { bubbles: true }));
try {
const keydownEvt = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
});
const keyupEvt = new KeyboardEvent('keyup', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
});
input.dispatchEvent(keydownEvt);
input.dispatchEvent(keyupEvt);
} catch {}
if (isTransportPayload) {
const restoreDraftIfNeeded = () => {
if (!previousValue) return;
const v = String(input.value || '');
if (v) return;
input.value = previousValue;
input.dispatchEvent(new Event('input', { bubbles: true }));
};
const shouldClearArtifact = () => {
if (!(input instanceof HTMLInputElement) && !(input instanceof HTMLTextAreaElement)) return false;
const v = String(input.value || '');
if (!v) return false;
if (v === '.') return true;
if (v.indexOf('\u2063') !== -1 || v.indexOf('\u2064') !== -1) return true;
if (v.startsWith('.\u2063\u2064')) return true;
return false;
};
let tries = 0;
const maxTries = 8;
const cleanup = () => {
tries += 1;
if (shouldClearArtifact()) {
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
restoreDraftIfNeeded();
return;
}
if (tries < maxTries) setTimeout(cleanup, 90);
else restoreDraftIfNeeded();
};
setTimeout(cleanup, 40);
}
return true;
}
function closeIngameChatInputByEnterTap(taps = 1) {
const inGameVisible =
typeof isElementActuallyVisible === 'function' &&
!!isElementActuallyVisible($('gamerenderer'));
if (!inGameVisible) return;
const input = $('ingamechatinputtext');
if (!(input instanceof HTMLInputElement) && !(input instanceof HTMLTextAreaElement)) return;
const cs = window.getComputedStyle(input);
if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return;
if (
(input.offsetWidth || 0) <= 0 &&
(input.offsetHeight || 0) <= 0 &&
(input.getClientRects ? input.getClientRects().length : 0) <= 0
) return;
const count = Math.max(1, parseInt(String(taps || '1'), 10) || 1);
const fireTap = () => {
try {
const keydownEvt = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
});
const keyupEvt = new KeyboardEvent('keyup', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
});
input.dispatchEvent(keydownEvt);
input.dispatchEvent(keyupEvt);
} catch {}
};
for (let i = 0; i < count; i += 1) {
setTimeout(fireTap, 35 * i);
}
}
function canRunGroupsSyncNow() {
const lobbyVisible =
typeof isElementActuallyVisible === 'function' &&
!!isElementActuallyVisible($('newbonklobby'));
const rendererVisible =
typeof isElementActuallyVisible === 'function' &&
!!isElementActuallyVisible($('gamerenderer'));
return lobbyVisible && !rendererVisible;
}
function canRunGroupsDesyncNow() {
const rendererVisible =
typeof isElementActuallyVisible === 'function' &&
!!isElementActuallyVisible($('gamerenderer'));
return !rendererVisible;
}
function broadcastSharedGroupsFromHost(force = false, opts = null) {
const silent = !!(opts && opts.silent);
const skipNoop = !!(opts && opts.skipNoop);
const useCarrierTransport = !!(opts && opts.transportMode === 'carrier');
const buildTransport = useCarrierTransport
? buildGroupsSyncTransportCarrierMessage
: buildGroupsSyncTransportMessage;
if (!isSelfLobbyHost()) return false;
if (!canRunGroupsSyncNow()) {
addSyncDisabledStatusNotice({ silent });
return false;
}
if (groupsSyncTask) {
if (!silent) addLocalChatStatus('[TBC] Sync already in progress. Please wait.', 'rgb(181, 48, 48)');
return false;
}
const now = Date.now();
if (!force && now < groupsRefreshSignalNextAllowedAt) {
if (!silent) {
const sec = Math.ceil((groupsRefreshSignalNextAllowedAt - now) / 1000);
addLocalChatStatus(`[TBC] Please wait ${sec}s before syncing again.`, 'rgb(181, 48, 48)');
}
return false;
}
let snapshot = [];
try {
snapshot = normalizeSharedGroupSnapshot(colorGroups);
} catch (e) {
console.error('[TBC] Failed to encode groups refresh payload', e);
return false;
}
const snapshotSig = getSharedGroupsSnapshotSig(snapshot);
if ((skipNoop || !force) && snapshotSig === groupsSyncLastSig) {
if (!silent) addLocalChatStatus('[TBC] Groups already up to date.');
return true;
}
groupsSyncLastSig = snapshotSig;
const fullPayload = encodeSharedGroupsPayload(snapshot);
let payload = fullPayload;
const deltaEnvelope = buildDeltaSyncEnvelope(sharedHostGroupsSnapshot || [], snapshot);
if (deltaEnvelope) {
try {
const deltaPayload = encodeSyncEnvelopeToB64(deltaEnvelope);
if (deltaPayload.length > 0 && deltaPayload.length < fullPayload.length) {
payload = deltaPayload;
}
} catch {}
}
if (payload.length > GROUPS_SYNC_MAX_PAYLOAD_CHARS) {
const chunks = [];
for (let i = 0; i < payload.length; i += GROUPS_SYNC_CHUNK_CHARS) {
chunks.push(payload.slice(i, i + GROUPS_SYNC_CHUNK_CHARS));
}
if (!chunks.length) chunks.push('');
const session = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
const total = chunks.length;
groupsRefreshSignalNextAllowedAt = now + (total * GROUPS_SYNC_CHUNK_INTERVAL_MS) + 1500;
groupsSyncTask = { session, total, sent: 0 };
if (groupsPanelVisible) renderGroupsPanel();
const sendNext = () => {
if (!groupsSyncTask || groupsSyncTask.session !== session) return;
const idx = groupsSyncTask.sent;
if (idx >= total) {
groupsSyncTask = null;
if (groupsPanelVisible) renderGroupsPanel();
if (!silent) addLocalChatStatus('[TBC] Groups sync completed.');
return;
}
const chunk = chunks[idx];
const rawMsg = `[TBCGREF|${session}|${idx + 1}/${total}|${chunk}]`;
const ok = sendLobbyChatMessage(buildTransport(rawMsg));
if (!ok) {
groupsSyncTask = null;
if (groupsPanelVisible) renderGroupsPanel();
if (!silent) addLocalChatStatus('[TBC] Sync failed: could not send chat payload.', 'rgb(181, 48, 48)');
return;
}
groupsSyncTask.sent += 1;
if (groupsPanelVisible) renderGroupsPanel();
setTimeout(sendNext, GROUPS_SYNC_CHUNK_INTERVAL_MS);
};
setTimeout(sendNext, 0);
if (!silent) {
const secs = Math.max(1, Math.round(GROUPS_SYNC_CHUNK_INTERVAL_MS / 1000));
addLocalChatStatus(`[TBC] Large payload detected. Syncing ${total} parts (1 part every ${secs}s)...`);
}
saveRoomGroupsCache(snapshot);
sharedHostGroupsSnapshot = normalizeSharedGroupSnapshot(snapshot);
liveGroupsSyncHostNorm = getLobbyHostNameNorm() || '';
setRoomGroupsSyncActive(true);
syncSharedGroupsBridge();
return true;
}
groupsRefreshSignalNextAllowedAt = now + 1400;
saveRoomGroupsCache(snapshot);
const sent = sendLobbyChatMessage(buildTransport(`[TBCGREF|${payload}]`));
if (sent) {
sharedHostGroupsSnapshot = normalizeSharedGroupSnapshot(snapshot);
liveGroupsSyncHostNorm = getLobbyHostNameNorm() || '';
setRoomGroupsSyncActive(true);
syncSharedGroupsBridge();
}
return sent;
}
function broadcastSharedGroupsDesyncFromHost() {
if (!isSelfLobbyHost()) return false;
if (!canRunGroupsDesyncNow()) {
addDesyncDisabledStatusNotice();
return false;
}
if (groupsSyncTask) return false;
const ok = sendLobbyChatMessage(buildGroupsSyncTransportMessage('[TBCGCLR]'));
if (!ok) return false;
groupsSyncLastSig = '';
sharedHostGroupsSnapshot = [];
setRoomGroupsSyncActive(false);
syncSharedGroupsBridge();
if (groupsPanelVisible) renderGroupsPanel();
return true;
}
function queueGroupsPanelActionAutoSync() {
if (groupsPanelAutoSyncTimer) {
clearTimeout(groupsPanelAutoSyncTimer);
groupsPanelAutoSyncTimer = null;
}
return;
}
function getGroupsPanelLobbyData(sourceGroups = colorGroups) {
const info = getLobbyAccountInfo();
const accountSet = info && info.accountSet ? info.accountSet : new Set();
const guestSet = info && info.guestSet ? info.guestSet : new Set();
const canFilterLobbyPresence = accountSet.size > 0 || guestSet.size > 0;
const groups = Array.isArray(sourceGroups) ? sourceGroups : [];
return groups
.map((g) => {
const name = String((g && g.name) || '').trim();
const color = String((g && g.color) || '').trim();
const playersRaw = Array.isArray(g && g.players) ? g.players : [];
const players = playersRaw
.map((p) => {
const pName = typeof p === 'string' ? String(p || '').trim() : String((p && p.name) || '').trim();
if (!pName) return null;
const pNorm = normalizeName(pName);
const pType = getGroupPlayerMemberType(p);
if (canFilterLobbyPresence) {
if (pType === 'guest' && !guestSet.has(pNorm)) return null;
if (pType === 'account' && !accountSet.has(pNorm)) return null;
if (pType === 'any' && !accountSet.has(pNorm) && !guestSet.has(pNorm)) return null;
}
const guestWarn = shouldShowGuestWarningForGroupMember(p, info);
return { name: pName, guestWarn, memberType: pType };
})
.filter(Boolean);
return { name, color, players };
})
.filter((g) => (g.players || []).length > 0);
}
function getHostGroupByName(groupName) {
const n = normalizeName(groupName);
return colorGroups.find((g) => normalizeName(g.name) === n) || null;
}
function openGroupsPanelPlayerManager(anchorEl, groupName, playerName, memberType = 'any') {
if (!canHostEditSharedGroupsPanel()) {
addLocalChatStatus('[TBC] Sync your groups first to enable player editing in Shared Groups.', 'rgb(181, 48, 48)');
return;
}
const fromGroup = getHostGroupByName(groupName);
if (!fromGroup) {
addLocalChatStatus('[TBC] Source group not found.', 'rgb(181, 48, 48)');
return;
}
openPanel(anchorEl, (panel) => {
const moveTargets = colorGroups
.filter((g) => g.id !== fromGroup.id)
.map((g) => `<div class="mod_ctx_item" data-tbc-groups-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</div>`)
.join('');
panel.innerHTML = `
<div class="mod_ctx_title">${escapeHtml(playerName)}</div>
<div style="font-size:11px;opacity:.8;margin-top:2px;">Current group: ${escapeHtml(fromGroup.name)}</div>
<div class="mod_ctx_items" style="margin-top:8px;">
<div class="mod_ctx_item" data-tbc-groups-open-move="1">Move player</div>
<div id="tbc_groups_move_targets" style="display:none;margin-top:4px;">
${moveTargets || '<div style="font-size:11px;opacity:.75;padding:4px 6px;">No other groups available.</div>'}
</div>
<div class="mod_ctx_item" data-tbc-groups-remove="1" style="color:#ff6b6b;">Remove from group</div>
</div>
`;
const moveToggle = panel.querySelector('[data-tbc-groups-open-move="1"]');
const moveTargetsHost = panel.querySelector('#tbc_groups_move_targets');
if (moveToggle && moveTargetsHost) {
moveToggle.addEventListener('click', () => {
const opening = moveTargetsHost.style.display === 'none';
moveTargetsHost.style.display = opening ? '' : 'none';
moveToggle.textContent = opening ? 'Move player (hide list)' : 'Move player';
});
}
panel.querySelectorAll('[data-tbc-groups-move]').forEach((btn) => {
btn.addEventListener('click', () => {
const toId = String(btn.getAttribute('data-tbc-groups-move') || '');
const res = movePlayerToGroup(fromGroup.id, toId, playerName, memberType);
if (!res || !res.ok) {
addLocalChatStatus(`[TBC] ${res && res.error ? res.error : 'Move failed.'}`, 'rgb(181, 48, 48)');
return;
}
closePanel();
renderGroupsPanel();
queueGroupsPanelActionAutoSync();
});
});
const removeBtn = panel.querySelector('[data-tbc-groups-remove="1"]');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
removePlayerFromGroup(fromGroup.id, playerName, memberType);
closePanel();
renderGroupsPanel();
queueGroupsPanelActionAutoSync();
});
}
});
}
function openGroupsPanelGroupManager(anchorEl, groupName) {
if (!canHostEditSharedGroupsPanel()) {
addLocalChatStatus('[TBC] Sync your groups first to enable editing in Shared Groups.', 'rgb(181, 48, 48)');
return;
}
const group = getHostGroupByName(groupName);
if (!group) {
addLocalChatStatus('[TBC] Group not found.', 'rgb(181, 48, 48)');
return;
}
openPanel(anchorEl, (panel) => {
panel.innerHTML = `
<div class="mod_ctx_title">Group: ${escapeHtml(group.name)}</div>
<div class="mod_ctx_items">
<div class="mod_ctx_item" data-tbc-groups-group-add="1">Add player</div>
<div class="mod_ctx_item" data-tbc-groups-group-rename="1">Rename group</div>
<div class="mod_ctx_item" data-tbc-groups-group-delete="1" style="color:#ff6b6b;">Delete group</div>
</div>
`;
const addBtn = panel.querySelector('[data-tbc-groups-group-add="1"]');
if (addBtn) {
addBtn.addEventListener('click', () => {
closePanel();
openPanel(anchorEl, (panel2) => {
panel2.innerHTML = `
<div class="mod_ctx_title">Add player to ${escapeHtml(group.name)}</div>
<input class="mod_ctx_input" type="text" placeholder="Player name">
<div class="mod_ctx_error" style="display:none;"></div>
<div class="mod_ctx_buttons">
<div class="mod_ctx_button mod_ctx_button_primary">Add</div>
<div class="mod_ctx_button">Cancel</div>
</div>
`;
const input = panel2.querySelector('.mod_ctx_input');
const errEl = panel2.querySelector('.mod_ctx_error');
const confirmBtn = panel2.querySelector('.mod_ctx_button_primary');
const cancelBtn = panel2.querySelectorAll('.mod_ctx_button')[1];
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
if (errEl) {
errEl.style.display = 'none';
errEl.textContent = '';
}
const res = addPlayerToGroup(group.id, input ? input.value : '');
if (!res || !res.ok) {
if (errEl) {
errEl.textContent = (res && res.error) ? res.error : 'Could not add player.';
errEl.style.display = 'block';
}
return;
}
closePanel();
renderGroupsPanel();
queueGroupsPanelActionAutoSync();
});
}
if (cancelBtn) cancelBtn.addEventListener('click', () => closePanel());
if (input) input.focus();
});
});
}
const renameBtn = panel.querySelector('[data-tbc-groups-group-rename="1"]');
if (renameBtn) {
renameBtn.addEventListener('click', () => {
closePanel();
openPanel(anchorEl, (panel2) => {
panel2.innerHTML = `
<div class="mod_ctx_title">Rename group</div>
<input class="mod_ctx_input" type="text" value="${escapeHtml(group.name)}">
<div class="mod_ctx_buttons">
<div class="mod_ctx_button mod_ctx_button_primary">Save</div>
<div class="mod_ctx_button">Cancel</div>
</div>
`;
const input = panel2.querySelector('.mod_ctx_input');
const saveBtn = panel2.querySelector('.mod_ctx_button_primary');
const cancelBtn = panel2.querySelectorAll('.mod_ctx_button')[1];
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const next = String((input && input.value) || '').trim();
if (!next) return;
renameGroup(group.id, next);
closePanel();
renderGroupsPanel();
queueGroupsPanelActionAutoSync();
});
}
if (cancelBtn) cancelBtn.addEventListener('click', () => closePanel());
if (input) {
input.focus();
input.select();
}
});
});
}
const deleteBtn = panel.querySelector('[data-tbc-groups-group-delete="1"]');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
closePanel();
openPanel(anchorEl, (panel2) => {
panel2.innerHTML = `
<div class="mod_ctx_title">Delete group?</div>
<div style="font-size:11px;opacity:0.8;margin-top:2px;">This will remove the group and all player assignments.</div>
<div class="mod_ctx_buttons">
<div class="mod_ctx_button mod_ctx_button_primary" style="background:rgba(255,65,65,0.7);border-color:rgba(255,65,65,0.9);">Delete</div>
<div class="mod_ctx_button">Cancel</div>
</div>
`;
const confirmBtn = panel2.querySelector('.mod_ctx_button_primary');
const cancelBtn = panel2.querySelectorAll('.mod_ctx_button')[1];
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
deleteGroup(group.id);
closePanel();
renderGroupsPanel();
queueGroupsPanelActionAutoSync();
});
}
if (cancelBtn) cancelBtn.addEventListener('click', () => closePanel());
});
});
}
});
}
function getPanelPlacementState(kind) {
return kind === 'points' ? pointsPanelPlacement : groupsPanelPlacement;
}
function clampPanelCoords(left, top, width, height) {
const maxLeft = Math.max(8, window.innerWidth - width - 8);
const maxTop = Math.max(8, window.innerHeight - height - 8);
return {
left: Math.min(maxLeft, Math.max(8, Math.round(left))),
top: Math.min(maxTop, Math.max(8, Math.round(top))),
};
}
function rectsOverlap(a, b, gap = 0) {
if (!a || !b) return false;
return (
a.left < (b.right + gap) &&
(a.right + gap) > b.left &&
a.top < (b.bottom + gap) &&
(a.bottom + gap) > b.top
);
}
function getVisiblePanelRect(kind) {
const id = kind === 'points' ? 'tbc_points_panel' : 'tbc_groups_panel';
const panel = $(id);
if (!panel || panel.style.display === 'none' || panel.getAttribute('aria-hidden') === 'true') return null;
const r = panel.getBoundingClientRect();
if (!Number.isFinite(r.width) || !Number.isFinite(r.height) || r.width < 20 || r.height < 20) return null;
return {
left: Math.round(r.left),
top: Math.round(r.top),
right: Math.round(r.right),
bottom: Math.round(r.bottom),
width: Math.round(r.width),
height: Math.round(r.height),
};
}
function getPanelSizeEstimate(kind) {
const placement = getPanelPlacementState(kind);
const fallbackWidth = kind === 'points' ? 300 : 248;
const width = Math.max(kind === 'points' ? 240 : 200, Math.round(placement.width || fallbackWidth));
const maxHeight = Math.max(220, Math.round(placement.maxHeight || 560));
const panelRect = getVisiblePanelRect(kind);
const height = panelRect
? Math.max(220, Math.round(panelRect.height))
: Math.max(220, Math.min(maxHeight, window.innerHeight - 16));
return { width, height };
}
function resolveDragPanelNoOverlap(kind, left, top, width, height) {
const otherKind = kind === 'points' ? 'groups' : 'points';
const obstacle = getVisiblePanelRect(otherKind);
if (!obstacle) return clampPanelCoords(left, top, width, height);
const desired = clampPanelCoords(left, top, width, height);
const desiredRect = {
left: desired.left,
top: desired.top,
right: desired.left + width,
bottom: desired.top + height,
};
if (!rectsOverlap(desiredRect, obstacle, 2)) return desired;
const gap = 10;
const candidateSeeds = [
{ left: desired.left, top: obstacle.bottom + gap },
{ left: desired.left, top: obstacle.top - height - gap },
{ left: obstacle.right + gap, top: desired.top },
{ left: obstacle.left - width - gap, top: desired.top },
];
let best = null;
let bestDist = Infinity;
candidateSeeds.forEach((seed) => {
const clamped = clampPanelCoords(seed.left, seed.top, width, height);
const rect = {
left: clamped.left,
top: clamped.top,
right: clamped.left + width,
bottom: clamped.top + height,
};
if (rectsOverlap(rect, obstacle, 2)) return;
const dist = Math.abs(clamped.left - desired.left) + Math.abs(clamped.top - desired.top);
if (dist < bestDist) {
bestDist = dist;
best = clamped;
}
});
if (best) return best;
return desired;
}
function applySlashPanelsSpawnLayout(primaryKind = 'groups', includePoints = false) {
const showGroups = !!groupsPanelVisible;
const showPoints = !!pointsPanelVisible && (includePoints || primaryKind === 'points');
if (!showGroups && !showPoints) return;
const gap = 10;
const baseLeft = 12;
const baseTop = 120;
const placeKind = primaryKind === 'points' ? 'points' : 'groups';
const secondaryKind = placeKind === 'groups' ? 'points' : 'groups';
const placeOne = (kind, left, top) => {
const size = getPanelSizeEstimate(kind);
const clamped = clampPanelCoords(left, top, size.width, size.height);
const placement = getPanelPlacementState(kind);
placement.left = clamped.left;
placement.top = clamped.top;
placement.width = size.width;
const maxAllowedHeight = Math.max(220, window.innerHeight - clamped.top - 8);
placement.maxHeight = Math.max(220, Math.min(placement.maxHeight || size.height, maxAllowedHeight));
if (kind === 'groups') groupsPanelPinnedByUser = true;
else pointsPanelPinnedByUser = true;
return { left: clamped.left, top: clamped.top, right: clamped.left + size.width, bottom: clamped.top + size.height, width: size.width, height: size.height };
};
const placeSecondaryAround = (kind, anchorRect) => {
const size = getPanelSizeEstimate(kind);
const belowTop = anchorRect.bottom + gap;
const aboveTop = anchorRect.top - size.height - gap;
const rightLeft = anchorRect.right + gap;
if (belowTop + size.height + 8 <= window.innerHeight) {
return placeOne(kind, anchorRect.left, belowTop);
}
if (aboveTop >= 8) {
return placeOne(kind, anchorRect.left, aboveTop);
}
if (rightLeft + size.width + 8 <= window.innerWidth) {
return placeOne(kind, rightLeft, anchorRect.top);
}
return placeOne(kind, baseLeft, baseTop);
};
const firstVisible = (placeKind === 'groups' ? showGroups : showPoints);
let firstRect = null;
if (firstVisible) {
firstRect = placeOne(placeKind, baseLeft, baseTop);
}
const secondVisible =
secondaryKind === 'groups' ? showGroups : showPoints;
if (secondVisible) {
if (!firstRect) {
firstRect = placeOne(secondaryKind, baseLeft, baseTop);
} else {
placeSecondaryAround(secondaryKind, firstRect);
}
}
if (groupsPanelVisible) positionGroupsPanel();
if (pointsPanelVisible) positionPointsPanel();
}
function ensureGroupsPanelElement() {
let panel = $('tbc_groups_panel');
if (panel) return panel;
const root = document.body;
if (!root) return null;
panel = document.createElement('div');
panel.id = 'tbc_groups_panel';
panel.style.display = 'none';
panel.setAttribute('aria-hidden', 'true');
root.appendChild(panel);
panel.addEventListener('click', (e) => {
e.stopPropagation();
const target = e.target && e.target.closest ? e.target.closest('[data-tbc-groups-action]') : null;
if (target) {
const action = target.getAttribute('data-tbc-groups-action');
if (action === 'close-panel') {
setGroupsPanelVisible(false);
return;
}
if (action === 'sync-now') {
if (!isSelfLobbyHost()) {
addLocalChatStatus('[TBC] Only the room host can trigger sync.', 'rgb(181, 48, 48)');
return;
}
if (!canRunGroupsSyncNow()) {
addSyncDisabledStatusNotice();
return;
}
uploadGroupsRelaySnapshot({ announceRelay: true, silent: true })
.then((ok) => {
if (ok) {
addLocalChatStatus('* Groups are now synced.');
setTimeout(() => closeIngameChatInputByEnterTap(1), 70);
} else {
addLocalChatStatus('[TBC] Sync failed. Groups were not shared.', 'rgb(181, 48, 48)');
}
})
.catch(() => {
addLocalChatStatus('[TBC] Sync failed. Groups were not shared.', 'rgb(181, 48, 48)');
});
} else if (action === 'desync-now') {
if (!isSelfLobbyHost()) {
addLocalChatStatus('[TBC] Only the room host can trigger desync.', 'rgb(181, 48, 48)');
return;
}
if (!canRunGroupsDesyncNow()) {
addDesyncDisabledStatusNotice();
return;
}
if (!roomGroupsSyncActive) {
addLocalChatStatus('[TBC] Sync first before desync.', 'rgb(181, 48, 48)');
return;
}
const ok = broadcastSharedGroupsDesyncFromHost();
if (ok) {
addLocalChatStatus('* Groups are now desynced.');
setTimeout(() => closeIngameChatInputByEnterTap(2), 70);
}
else addLocalChatStatus('[TBC] Could not send desync signal.', 'rgb(181, 48, 48)');
}
}
const playerBtn = e.target && e.target.closest ? e.target.closest('[data-tbc-groups-player-action]') : null;
if (playerBtn) {
if (!canHostEditSharedGroupsPanel()) {
addLocalChatStatus('[TBC] Sync your groups first to enable player editing in Shared Groups.', 'rgb(181, 48, 48)');
return;
}
const playerName = String(playerBtn.getAttribute('data-tbc-groups-player') || '').trim();
const groupName = String(playerBtn.getAttribute('data-tbc-groups-group') || '').trim();
const memberType = normalizeMemberType(String(playerBtn.getAttribute('data-tbc-groups-member-type') || 'any'));
if (!playerName || !groupName) return;
openGroupsPanelPlayerManager(playerBtn, groupName, playerName, memberType);
}
const groupBtn = e.target && e.target.closest ? e.target.closest('[data-tbc-groups-group-action]') : null;
if (groupBtn) {
if (!canHostEditSharedGroupsPanel()) {
addLocalChatStatus('[TBC] Sync your groups first to enable group editing.', 'rgb(181, 48, 48)');
return;
}
const groupName = String(groupBtn.getAttribute('data-tbc-groups-group') || '').trim();
if (!groupName) return;
openGroupsPanelGroupManager(groupBtn, groupName);
}
});
const onMove = () => {
if (!groupsPanelVisible) return;
positionGroupsPanel();
};
window.addEventListener('resize', onMove);
window.addEventListener('scroll', onMove, true);
panel.addEventListener('pointerdown', (e) => {
const closeBtn = e.target && e.target.closest ? e.target.closest('[data-tbc-groups-action="close-panel"]') : null;
if (closeBtn) return;
const handle = e.target && e.target.closest ? e.target.closest('[data-tbc-groups-drag-handle="1"]') : null;
if (!handle) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
const rect = panel.getBoundingClientRect();
groupsPanelPinnedByUser = true;
groupsPanelDragState.active = true;
groupsPanelDragState.pointerId = e.pointerId;
groupsPanelDragState.offsetX = e.clientX - rect.left;
groupsPanelDragState.offsetY = e.clientY - rect.top;
panel.classList.add('tbc_groups_panel_dragging');
try {
if (handle.setPointerCapture) handle.setPointerCapture(e.pointerId);
} catch (_) {}
e.preventDefault();
});
const endDrag = () => {
if (!groupsPanelDragState.active) return;
groupsPanelDragState.active = false;
groupsPanelDragState.pointerId = null;
panel.classList.remove('tbc_groups_panel_dragging');
};
window.addEventListener('pointermove', (e) => {
if (!groupsPanelDragState.active) return;
if (groupsPanelDragState.pointerId !== null && e.pointerId !== groupsPanelDragState.pointerId) return;
const width = panel.offsetWidth || groupsPanelPlacement.width || 248;
const height = panel.offsetHeight || 320;
const maxLeft = Math.max(8, window.innerWidth - width - 8);
const maxTop = Math.max(8, window.innerHeight - height - 8);
const nextLeft = Math.min(maxLeft, Math.max(8, Math.round(e.clientX - groupsPanelDragState.offsetX)));
const nextTop = Math.min(maxTop, Math.max(8, Math.round(e.clientY - groupsPanelDragState.offsetY)));
const adjusted = resolveDragPanelNoOverlap('groups', nextLeft, nextTop, width, height);
groupsPanelPlacement.left = adjusted.left;
groupsPanelPlacement.top = adjusted.top;
const maxAllowedHeight = Math.max(220, window.innerHeight - groupsPanelPlacement.top - 8);
groupsPanelPlacement.maxHeight = Math.max(220, Math.min(groupsPanelPlacement.maxHeight || maxAllowedHeight, maxAllowedHeight));
panel.style.left = `${groupsPanelPlacement.left}px`;
panel.style.top = `${groupsPanelPlacement.top}px`;
panel.style.maxHeight = `${groupsPanelPlacement.maxHeight}px`;
e.preventDefault();
});
window.addEventListener('pointerup', endDrag);
window.addEventListener('pointercancel', endDrag);
return panel;
}
function positionGroupsPanel() {
const panel = $('tbc_groups_panel');
if (!panel) return;
const lobby = $('newbonklobby');
const playerbox = $('newbonklobby_playerbox');
const settingsbox = $('newbonklobby_settingsbox');
const lobbyRect = lobby ? lobby.getBoundingClientRect() : null;
const playerRect = playerbox ? playerbox.getBoundingClientRect() : null;
const settingsRect = settingsbox ? settingsbox.getBoundingClientRect() : null;
const hasAnchorRect =
!!lobbyRect &&
Number.isFinite(lobbyRect.left) &&
Number.isFinite(lobbyRect.top) &&
lobbyRect.width > 10 &&
lobbyRect.height > 10;
if (hasAnchorRect && !groupsPanelPinnedByUser) {
const desiredWidth = 248;
const anchorTop = (playerRect && playerRect.height > 10) ? playerRect.top : lobbyRect.top + 72;
const anchorBottom = (settingsRect && settingsRect.height > 10) ? settingsRect.bottom : (lobbyRect.bottom - 10);
const desiredHeight = Math.max(240, Math.round(anchorBottom - anchorTop));
const left = 12;
const top = Math.max(8, Math.round(anchorTop));
groupsPanelPlacement.left = left;
groupsPanelPlacement.top = top;
groupsPanelPlacement.width = desiredWidth;
groupsPanelPlacement.maxHeight = Math.max(220, Math.min(desiredHeight, window.innerHeight - top - 8));
}
const safeWidth = Math.max(200, Number.isFinite(groupsPanelPlacement.width) ? Math.round(groupsPanelPlacement.width) : 248);
groupsPanelPlacement.width = safeWidth;
const panelRect = panel.getBoundingClientRect();
const panelHeight = panelRect && panelRect.height > 10
? panelRect.height
: Math.max(220, Math.min(Number.isFinite(groupsPanelPlacement.maxHeight) ? groupsPanelPlacement.maxHeight : 560, window.innerHeight - 16));
const maxLeft = Math.max(8, window.innerWidth - safeWidth - 8);
const maxTop = Math.max(8, window.innerHeight - panelHeight - 8);
groupsPanelPlacement.left = Math.min(maxLeft, Math.max(8, Math.round(groupsPanelPlacement.left || 12)));
groupsPanelPlacement.top = Math.min(maxTop, Math.max(8, Math.round(groupsPanelPlacement.top || 120)));
const maxAllowedHeight = Math.max(220, window.innerHeight - groupsPanelPlacement.top - 8);
groupsPanelPlacement.maxHeight = Math.max(220, Math.min(Number.isFinite(groupsPanelPlacement.maxHeight) ? groupsPanelPlacement.maxHeight : maxAllowedHeight, maxAllowedHeight));
panel.style.left = `${groupsPanelPlacement.left}px`;
panel.style.top = `${groupsPanelPlacement.top}px`;
panel.style.width = `${groupsPanelPlacement.width}px`;
panel.style.maxHeight = `${groupsPanelPlacement.maxHeight}px`;
}
function renderGroupsPanel() {
const panel = ensureGroupsPanelElement();
if (!panel) return;
const hostView = isSelfLobbyHost();
const hostCanEdit = canHostEditSharedGroupsPanel();
const hostCanSyncNow = hostView && !groupsSyncTask && canRunGroupsSyncNow();
const hostCanDesyncNow = hostView && !groupsSyncTask && roomGroupsSyncActive && canRunGroupsDesyncNow();
const groups = hostView
? getGroupsPanelLobbyData(colorGroups)
: getGroupsPanelLobbyData(sharedHostGroupsSnapshot);
const groupsHtml = groups.length
? groups
.map((g) => {
const canManage = hostCanEdit;
const players = g.players
.map((p) => {
const pName = String((p && p.name) || '').trim();
const guestWarn = !!(p && p.guestWarn);
const memberType = normalizeMemberType(String((p && p.memberType) || 'any'));
const safeName = escapeHtml(pName);
const safeMemberType = escapeHtml(memberType);
const guestWarnHtml = guestWarn
? '<span class="tbc_guest_warn_badge" title="Temporary guest member. Removed when guest/room/host state changes." aria-label="Temporary guest member"></span>'
: '';
const safeGroup = escapeHtml(g.name);
const hostBtns = hostView
? `
<div class="tbc_groups_player_actions">
<button class="tbc_groups_player_btn" data-tbc-groups-player-action="manage" data-tbc-groups-player="${safeName}" data-tbc-groups-group="${safeGroup}" data-tbc-groups-member-type="${safeMemberType}" title="${canManage ? 'Manage player' : 'Sync first to enable editing'}">⋮</button>
</div>
`
: '';
return `
<div class="tbc_groups_player${hostView ? ' has-actions' : ''}"${hostView ? ` data-tbc-groups-player-row="1" data-tbc-groups-player="${safeName}" data-tbc-groups-group="${safeGroup}" data-tbc-groups-member-type="${safeMemberType}"` : ''}>
<span class="tbc_groups_player_name">${safeName}${guestWarnHtml}</span>
${hostBtns}
</div>
`;
})
.join('');
return `
<div class="tbc_groups_card">
<div class="tbc_groups_card_top">
<span class="tbc_groups_dot" style="background:${escapeHtml(g.color)}"></span>
<span class="tbc_groups_name">${escapeHtml(g.name)}</span>
${hostView ? `<button class="tbc_groups_group_btn" data-tbc-groups-group-action="manage" data-tbc-groups-group="${escapeHtml(g.name)}" title="${hostCanEdit ? 'Manage group' : 'Sync first to enable editing'}">⋮</button>` : ''}
<span class="tbc_groups_count">${g.players.length}</span>
</div>
<div class="tbc_groups_players">${players || '<span class="tbc_groups_empty">No players</span>'}</div>
</div>
`;
})
.join('')
: `<div class="tbc_groups_empty">${roomGroupsSyncActive ? 'Host synced with no groups.' : 'No shared groups synced yet.'}</div>`;
panel.innerHTML = `
<div class="tbc_groups_window windowShadow">
<div class="tbc_groups_top" data-tbc-groups-drag-handle="1">
<span class="tbc_groups_drag_grip" aria-hidden="true">
<span class="tbc_groups_drag_grip_dot"></span><span class="tbc_groups_drag_grip_dot"></span><span class="tbc_groups_drag_grip_dot"></span>
<span class="tbc_groups_drag_grip_dot"></span><span class="tbc_groups_drag_grip_dot"></span><span class="tbc_groups_drag_grip_dot"></span>
<span class="tbc_groups_drag_grip_dot"></span><span class="tbc_groups_drag_grip_dot"></span><span class="tbc_groups_drag_grip_dot"></span>
</span>
<span class="tbc_groups_top_title">Shared Groups</span>
<button class="tbc_groups_close_btn" data-tbc-groups-action="close-panel" title="Close" aria-label="Close panel">x</button>
</div>
<div class="tbc_groups_body">
<div class="tbc_groups_head">
<div class="tbc_groups_sub">Showing host groups for players currently in lobby (including temporary guests).</div>
<div class="tbc_groups_actions">
<button class="tbc_groups_btn" data-tbc-groups-action="sync-now"${hostCanSyncNow ? '' : ' disabled'}>${groupsSyncTask ? `Syncing... ${groupsSyncTask.sent}/${groupsSyncTask.total}` : 'Sync'}</button>
<button class="tbc_groups_btn" data-tbc-groups-action="desync-now"${hostCanDesyncNow ? '' : ' disabled'}>Desync</button>
</div>
</div>
<div class="tbc_groups_hint">${hostView && !hostCanEdit ? 'Sync your groups once to enable editing in Shared Groups.' : 'Auto-updates on join/leave are local. Only manual Sync sends chat sync data.'}</div>
<div class="tbc_groups_list">${groupsHtml}</div>
</div>
</div>
`;
const closeBtnEl = panel.querySelector('[data-tbc-groups-action="close-panel"]');
const settingsCloseEl = $('settings_close');
if (closeBtnEl && settingsCloseEl) {
closeBtnEl.className = settingsCloseEl.className || closeBtnEl.className;
closeBtnEl.classList.add('tbc_groups_close_btn');
}
positionGroupsPanel();
panel.setAttribute('aria-hidden', groupsPanelVisible ? 'false' : 'true');
}
function setGroupsPanelVisible(visible) {
groupsPanelVisible = !!visible;
const panel = ensureGroupsPanelElement();
if (!panel) return;
if (!groupsPanelVisible) {
panel.style.display = 'none';
panel.setAttribute('aria-hidden', 'true');
return;
}
renderGroupsPanel();
panel.style.display = '';
panel.setAttribute('aria-hidden', 'false');
}
function clonePointsEntries(entries) {
return (Array.isArray(entries) ? entries : [])
.map((e) => ({ name: String((e && e.name) || '').trim(), score: Number((e && e.score) || 0) }))
.filter((e) => e.name);
}
function collectScoreEntriesFromStatusMessages() {
const readRows = (host) => Array.from((host && host.children) ? host.children : [])
.map((row) => {
const txt = String((row && (row.textContent || row.innerText)) || '').replace(/\s+/g, ' ').trim();
return txt;
})
.filter(Boolean);
const ingameRows = readRows($('ingamechatcontent'));
const lobbyRows = readRows($('newbonklobby_chat_content'));
const rows = ingameRows.concat(lobbyRows);
if (!rows.length) return [];
const map = new Map();
rows.forEach((raw) => {
const text = String(raw || '').trim();
if (!text) return;
if (/^\*\s*game starting in\s+\d+/i.test(text)) {
map.clear();
return;
}
const scoreMatch = text.match(/^\*\s*(.+?)\s+scores!?$/i);
if (!scoreMatch) return;
const name = canonicalizeWinnerName(extractChatName(String(scoreMatch[1] || '').trim()));
const norm = normalizeWinnerLookupName(name);
if (!name || !norm) return;
const prev = map.get(norm) || { name, score: 0 };
prev.score += 1;
map.set(norm, prev);
});
return Array.from(map.values())
.map((e) => ({ name: String(e.name || '').trim(), score: Number(e.score) || 0 }))
.filter((e) => e.name);
}
function getPointsPanelSourceEntries() {
const rendererVisible = isElementActuallyVisibleSafe($('gamerenderer'));
const winnerVisible = isElementActuallyVisibleSafe($('ingamewinner'));
const canSampleLive = rendererVisible && winnerVisible;
const roomSynced = !!window.tbcRoomGroupsSyncActive;
if (canSampleLive) {
const left = document.getElementById('ingamewinner_scores_left');
const right = document.getElementById('ingamewinner_scores_right');
const parsed = left ? parseWinnerEntriesFromDom(left, right) : [];
const parsedHasScoreRows = Array.isArray(parsed) && parsed.some((e) => !isLikelyTeamWinnerName((e && e.name) || ''));
let source = clonePointsEntries(winnerSourceEntries);
if (!source.length || (!roomSynced && parsedHasScoreRows)) source = clonePointsEntries(parsed);
if (roomSynced && !source.length) source = clonePointsEntries(parsed);
const statusEntries = collectScoreEntriesFromStatusMessages();
if (statusEntries.length) {
const statusMap = new Map(statusEntries.map((e) => [normalizeWinnerLookupName(e.name), e]));
if (source.length) {
const seen = new Set();
source = source.map((e) => {
const key = normalizeWinnerLookupName(e.name);
seen.add(key);
const statusRow = statusMap.get(key);
if (!statusRow) return e;
return { name: e.name, score: Math.max(Number(e.score) || 0, Number(statusRow.score) || 0) };
});
statusEntries.forEach((e) => {
const key = normalizeWinnerLookupName(e.name);
if (!seen.has(key)) source.push({ name: e.name, score: Number(e.score) || 0 });
});
} else {
source = clonePointsEntries(statusEntries);
}
}
if (source.length) {
pointsPanelCachedSourceEntries = source;
}
}
return clonePointsEntries(pointsPanelCachedSourceEntries);
}
function getGroupedPointsEntries(sourceEntries) {
const sharedSyncActive = !!window.tbcRoomGroupsSyncActive;
if (!sharedSyncActive) return [];
const grouped = buildGroupedWinnerEntries(sourceEntries);
if (Array.isArray(grouped) && grouped.length) return grouped;
const rows = Array.isArray(sourceEntries) ? sourceEntries : [];
const nonTeamRows = rows.filter((r) => r && !isLikelyTeamWinnerName(String(r.name || '')));
if (!nonTeamRows.length) return [];
const snapshot = normalizeSharedGroupSnapshot(window.tbcSharedGroupsSnapshot || []);
const buckets = new Map();
snapshot.forEach((g) => {
const key = normalizeWinnerLookupName(g && g.name);
if (!key) return;
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(g);
});
const pickedCount = new Map();
const out = nonTeamRows.map((r, idx) => {
const rowName = String((r && r.name) || '').trim();
const key = normalizeWinnerLookupName(rowName);
const bucket = key ? (buckets.get(key) || []) : [];
const used = key ? (pickedCount.get(key) || 0) : 0;
const picked = bucket[used] || null;
if (key) pickedCount.set(key, used + 1);
return {
name: rowName || `Group ${idx + 1}`,
score: Number((r && r.score) || 0),
color: String((picked && picked.color) || ''),
contributors: [],
};
});
return out;
}
function buildPointsPlayersHtml(entries, colorResolver = null) {
if (!entries.length) return '<div class="tbc_points_empty">No score data yet.</div>';
return entries
.map((entry) => {
const name = String(entry.name || '').trim();
const score = Number(entry.score) || 0;
const color = colorResolver
? String(colorResolver(name, entry) || '')
: (getWinnerBoardColorForName(name) || '');
const dotStyle = color ? ` style="background:${escapeHtml(color)}"` : '';
return `
<div class="tbc_points_row">
<span class="tbc_points_dot"${dotStyle}></span>
<span class="tbc_points_name">${escapeHtml(name)}</span>
<span class="tbc_points_score">${score}</span>
</div>
`;
})
.join('');
}
function buildPointsGroupsHtml(grouped) {
if (!grouped.length) return '<div class="tbc_points_empty">No grouped score data yet.</div>';
return grouped
.map((group) => {
const gName = String(group.name || '').trim() || 'Group';
const gScore = Number(group.score) || 0;
const gColor = String(group.color || '').trim();
const dotStyle = gColor ? ` style="background:${escapeHtml(gColor)}"` : '';
const contributors = Array.isArray(group.contributors) ? group.contributors : [];
const contribHtml = contributors.length
? contributors
.map((c) => {
const cName = String((c && c.name) || '').trim();
const cScore = Number((c && c.score) || 0);
return `
<div class="tbc_points_contrib_row">
<span class="tbc_points_contrib_name">${escapeHtml(cName)}</span>
<span class="tbc_points_contrib_score">${cScore}</span>
</div>
`;
})
.join('')
: '<div class="tbc_points_empty">No contributors</div>';
return `
<div class="tbc_points_group_card">
<div class="tbc_points_row">
<span class="tbc_points_dot"${dotStyle}></span>
<span class="tbc_points_name">${escapeHtml(gName)}</span>
<span class="tbc_points_score">${gScore}</span>
</div>
<div class="tbc_points_contribs">${contribHtml}</div>
</div>
`;
})
.join('');
}
function ensurePointsPanelElement() {
let panel = $('tbc_points_panel');
if (panel) return panel;
const root = document.body;
if (!root) return null;
panel = document.createElement('div');
panel.id = 'tbc_points_panel';
panel.style.display = 'none';
panel.setAttribute('aria-hidden', 'true');
root.appendChild(panel);
panel.addEventListener('click', (e) => {
e.stopPropagation();
const target = e.target && e.target.closest ? e.target.closest('[data-tbc-points-action]') : null;
if (!target) return;
const action = target.getAttribute('data-tbc-points-action');
if (action === 'close-panel') setPointsPanelVisible(false);
});
const onMove = () => {
if (!pointsPanelVisible) return;
positionPointsPanel();
};
window.addEventListener('resize', onMove);
window.addEventListener('scroll', onMove, true);
panel.addEventListener('pointerdown', (e) => {
const closeBtn = e.target && e.target.closest ? e.target.closest('[data-tbc-points-action="close-panel"]') : null;
if (closeBtn) return;
const handle = e.target && e.target.closest ? e.target.closest('[data-tbc-points-drag-handle="1"]') : null;
if (!handle) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
const rect = panel.getBoundingClientRect();
pointsPanelPinnedByUser = true;
pointsPanelDragState.active = true;
pointsPanelDragState.pointerId = e.pointerId;
pointsPanelDragState.offsetX = e.clientX - rect.left;
pointsPanelDragState.offsetY = e.clientY - rect.top;
panel.classList.add('tbc_points_panel_dragging');
try {
if (handle.setPointerCapture) handle.setPointerCapture(e.pointerId);
} catch (_) {}
e.preventDefault();
});
const endDrag = () => {
if (!pointsPanelDragState.active) return;
pointsPanelDragState.active = false;
pointsPanelDragState.pointerId = null;
panel.classList.remove('tbc_points_panel_dragging');
};
window.addEventListener('pointermove', (e) => {
if (!pointsPanelDragState.active) return;
if (pointsPanelDragState.pointerId !== null && e.pointerId !== pointsPanelDragState.pointerId) return;
const width = panel.offsetWidth || pointsPanelPlacement.width || 300;
const height = panel.offsetHeight || 320;
const maxLeft = Math.max(8, window.innerWidth - width - 8);
const maxTop = Math.max(8, window.innerHeight - height - 8);
const nextLeft = Math.min(maxLeft, Math.max(8, Math.round(e.clientX - pointsPanelDragState.offsetX)));
const nextTop = Math.min(maxTop, Math.max(8, Math.round(e.clientY - pointsPanelDragState.offsetY)));
const adjusted = resolveDragPanelNoOverlap('points', nextLeft, nextTop, width, height);
pointsPanelPlacement.left = adjusted.left;
pointsPanelPlacement.top = adjusted.top;
const maxAllowedHeight = Math.max(220, window.innerHeight - pointsPanelPlacement.top - 8);
pointsPanelPlacement.maxHeight = Math.max(220, Math.min(pointsPanelPlacement.maxHeight || maxAllowedHeight, maxAllowedHeight));
panel.style.left = `${pointsPanelPlacement.left}px`;
panel.style.top = `${pointsPanelPlacement.top}px`;
panel.style.maxHeight = `${pointsPanelPlacement.maxHeight}px`;
e.preventDefault();
});
window.addEventListener('pointerup', endDrag);
window.addEventListener('pointercancel', endDrag);
return panel;
}
function positionPointsPanel() {
const panel = $('tbc_points_panel');
if (!panel) return;
const lobby = $('newbonklobby');
const playerbox = $('newbonklobby_playerbox');
const settingsbox = $('newbonklobby_settingsbox');
const lobbyRect = lobby ? lobby.getBoundingClientRect() : null;
const playerRect = playerbox ? playerbox.getBoundingClientRect() : null;
const settingsRect = settingsbox ? settingsbox.getBoundingClientRect() : null;
const hasAnchorRect =
!!lobbyRect &&
Number.isFinite(lobbyRect.left) &&
Number.isFinite(lobbyRect.top) &&
lobbyRect.width > 10 &&
lobbyRect.height > 10;
if (hasAnchorRect && !pointsPanelPinnedByUser) {
const desiredWidth = 300;
const anchorTop = (playerRect && playerRect.height > 10) ? playerRect.top : lobbyRect.top + 72;
const anchorBottom = (settingsRect && settingsRect.height > 10) ? settingsRect.bottom : (lobbyRect.bottom - 10);
const desiredHeight = Math.max(240, Math.round(anchorBottom - anchorTop));
pointsPanelPlacement.left = Math.max(8, Math.round((groupsPanelPlacement.left || 12) + (groupsPanelPlacement.width || 248) + 10));
pointsPanelPlacement.top = Math.max(8, Math.round(anchorTop));
pointsPanelPlacement.width = desiredWidth;
pointsPanelPlacement.maxHeight = Math.max(220, Math.min(desiredHeight, window.innerHeight - pointsPanelPlacement.top - 8));
}
const safeWidth = Math.max(240, Number.isFinite(pointsPanelPlacement.width) ? Math.round(pointsPanelPlacement.width) : 300);
pointsPanelPlacement.width = safeWidth;
const panelRect = panel.getBoundingClientRect();
const panelHeight = panelRect && panelRect.height > 10
? panelRect.height
: Math.max(220, Math.min(Number.isFinite(pointsPanelPlacement.maxHeight) ? pointsPanelPlacement.maxHeight : 560, window.innerHeight - 16));
const maxLeft = Math.max(8, window.innerWidth - safeWidth - 8);
const maxTop = Math.max(8, window.innerHeight - panelHeight - 8);
pointsPanelPlacement.left = Math.min(maxLeft, Math.max(8, Math.round(pointsPanelPlacement.left || 270)));
pointsPanelPlacement.top = Math.min(maxTop, Math.max(8, Math.round(pointsPanelPlacement.top || 120)));
const maxAllowedHeight = Math.max(220, window.innerHeight - pointsPanelPlacement.top - 8);
pointsPanelPlacement.maxHeight = Math.max(220, Math.min(Number.isFinite(pointsPanelPlacement.maxHeight) ? pointsPanelPlacement.maxHeight : maxAllowedHeight, maxAllowedHeight));
panel.style.left = `${pointsPanelPlacement.left}px`;
panel.style.top = `${pointsPanelPlacement.top}px`;
panel.style.width = `${pointsPanelPlacement.width}px`;
panel.style.maxHeight = `${pointsPanelPlacement.maxHeight}px`;
}
function renderPointsPanel() {
const panel = ensurePointsPanelElement();
if (!panel) return;
const sourceEntries = getPointsPanelSourceEntries()
.slice()
.sort((a, b) => (Number(b.score) || 0) - (Number(a.score) || 0) || String(a.name || '').localeCompare(String(b.name || '')));
const roomSynced = !!window.tbcRoomGroupsSyncActive;
const teamsOff = isTeamsOffForWinnerBoard();
const hasTeamRows = sourceEntries.some((e) => isLikelyTeamWinnerName(e.name));
const syncedSupportedNow = hasTeamRows ? false : teamsOff;
const grouped = roomSynced && syncedSupportedNow ? getGroupedPointsEntries(sourceEntries) : [];
const groupedOnly = grouped.filter((g) => !(g && g.isUngroupedPlayer));
const ungroupedSyncedPlayers = grouped
.filter((g) => !!(g && g.isUngroupedPlayer))
.map((g) => ({
name: String((g && g.name) || '').trim(),
score: Number((g && g.score) || 0),
}))
.filter((e) => !!e.name);
const modeKey = roomSynced ? (syncedSupportedNow ? 'synced' : 'syncedTeamsBlocked') : 'unsynced';
const sig = JSON.stringify({
m: modeKey,
s: sourceEntries.map((e) => [e.name, e.score]),
g: groupedOnly.map((g) => [g.name, g.score, g.color || '', (g.contributors || []).map((c) => [c.name, c.score])]),
u: ungroupedSyncedPlayers.map((e) => [e.name, e.score]),
});
if (sig !== pointsPanelLastRenderSig) {
let bodyHtml = '';
if (roomSynced && !syncedSupportedNow) {
bodyHtml = `
<div class="tbc_points_warn">Won't work in teams mode. Group totals are only available in Teams Off / FFA.</div>
`;
} else if (roomSynced) {
const syncedUngroupedColorFn = (name) => getDisplayColorForName(name) || '';
const ungroupedHtml = ungroupedSyncedPlayers.length
? `
<div class="tbc_points_section">
<div class="tbc_points_section_title">Ungrouped Players</div>
<div class="tbc_points_list">${buildPointsPlayersHtml(ungroupedSyncedPlayers, syncedUngroupedColorFn)}</div>
</div>
`
: '';
bodyHtml = `
<div class="tbc_points_hint">Synced room: showing host shared-group totals and contributors.</div>
<div class="tbc_points_section">
<div class="tbc_points_section_title">Group Totals</div>
<div class="tbc_points_list">${buildPointsGroupsHtml(groupedOnly)}</div>
</div>
${ungroupedHtml}
`;
} else {
bodyHtml = `
<div class="tbc_points_hint">Unsynced room: showing player scoreboard snapshot.</div>
<div class="tbc_points_section">
<div class="tbc_points_section_title">Player Scores</div>
<div class="tbc_points_list">${buildPointsPlayersHtml(sourceEntries)}</div>
</div>
`;
}
panel.innerHTML = `
<div class="tbc_points_window windowShadow">
<div class="tbc_points_top" data-tbc-points-drag-handle="1">
<span class="tbc_points_drag_grip" aria-hidden="true">
<span class="tbc_points_drag_grip_dot"></span><span class="tbc_points_drag_grip_dot"></span><span class="tbc_points_drag_grip_dot"></span>
<span class="tbc_points_drag_grip_dot"></span><span class="tbc_points_drag_grip_dot"></span><span class="tbc_points_drag_grip_dot"></span>
<span class="tbc_points_drag_grip_dot"></span><span class="tbc_points_drag_grip_dot"></span><span class="tbc_points_drag_grip_dot"></span>
</span>
<span class="tbc_points_top_title">Points</span>
<button class="tbc_points_close_btn" data-tbc-points-action="close-panel" title="Close" aria-label="Close panel">x</button>
</div>
<div class="tbc_points_body">
${bodyHtml}
</div>
</div>
`;
const closeBtnEl = panel.querySelector('[data-tbc-points-action="close-panel"]');
const settingsCloseEl = $('settings_close');
if (closeBtnEl && settingsCloseEl) {
closeBtnEl.className = settingsCloseEl.className || closeBtnEl.className;
closeBtnEl.classList.add('tbc_points_close_btn');
}
pointsPanelLastRenderSig = sig;
}
positionPointsPanel();
panel.setAttribute('aria-hidden', pointsPanelVisible ? 'false' : 'true');
}
function setPointsPanelVisible(visible) {
pointsPanelVisible = !!visible;
const panel = ensurePointsPanelElement();
if (!panel) return;
if (!pointsPanelVisible) {
panel.style.display = 'none';
panel.setAttribute('aria-hidden', 'true');
return;
}
renderPointsPanel();
panel.style.display = '';
panel.setAttribute('aria-hidden', 'false');
}
window.tbcRenderPointsPanel = () => renderPointsPanel();
window.tbcIsPointsPanelVisible = () => !!pointsPanelVisible;
window.tbcInvalidatePointsPanel = () => {
pointsPanelLastRenderSig = '';
};
window.tbcResetPointsPanelCache = () => {
pointsPanelCachedSourceEntries = [];
pointsPanelLastRenderSig = '';
};
function handleIncomingGroupsSyncChunk(senderName, msgText) {
const relayRaw = String(msgText || '').trim();
const relayMsgPipe = relayRaw.match(/^\[TBCGRELAY\|([^\]]+)\]$/i);
const relayMsgBare = relayRaw.match(/^\[TBCGRELAY\]([A-Za-z0-9._:-]+)$/i);
const relayTokenFromMsg = relayMsgPipe ? String(relayMsgPipe[1] || '').trim() : (relayMsgBare ? String(relayMsgBare[1] || '').trim() : '');
if (relayTokenFromMsg) {
const hostNorm = getLobbyHostNameNorm();
const senderNorm = normalizeName(senderName);
if (!hostNorm || senderNorm !== hostNorm) return false;
const parsedRelay = parseGroupsRelayToken(relayTokenFromMsg);
if (!parsedRelay) return true;
if (!isSelfLobbyHost()) pullGroupsRelaySnapshot(relayTokenFromMsg);
return true;
}
const clearMsg = String(msgText || '').trim().match(/^\[TBCGCLR(?:\|.*)?\]$/i);
if (clearMsg) {
const hostNorm = getLobbyHostNameNorm();
const senderNorm = normalizeName(senderName);
if (!hostNorm || senderNorm !== hostNorm) return false;
sharedHostGroupsSnapshot = [];
saveRoomGroupsCache([]);
liveGroupsSyncHostNorm = '';
setRoomGroupsSyncActive(false);
syncSharedGroupsBridge();
if (groupsPanelVisible) renderGroupsPanel();
addGroupsLifecycleStatus('Host desynced shared groups...using your own color groups');
return true;
}
const chunkMsg = String(msgText || '').trim().match(/^\[TBCGREF\|([a-z0-9_]+)\|(\d+)\/(\d+)\|([A-Za-z0-9+/=]*)\]$/i);
if (chunkMsg) {
const hostNorm = getLobbyHostNameNorm();
const senderNorm = normalizeName(senderName);
if (!hostNorm || senderNorm !== hostNorm) return false;
const session = chunkMsg[1];
const idx = Math.max(1, parseInt(chunkMsg[2], 10) || 1);
const total = Math.max(1, parseInt(chunkMsg[3], 10) || 1);
const chunk = chunkMsg[4] || '';
let bag = groupsSyncChunksBySession.get(session);
if (!bag) {
bag = { total, chunks: new Array(total).fill(''), filled: 0, createdAt: Date.now() };
groupsSyncChunksBySession.set(session, bag);
}
if (bag.total !== total) return true;
if (!bag.chunks[idx - 1]) {
bag.chunks[idx - 1] = chunk;
bag.filled += 1;
}
for (const [sid, entry] of groupsSyncChunksBySession.entries()) {
if ((Date.now() - entry.createdAt) > 60000) groupsSyncChunksBySession.delete(sid);
}
if (bag.filled < bag.total) return true;
try {
const payload = bag.chunks.join('');
sharedHostGroupsSnapshot = decodeSharedGroupsPayload(payload, sharedHostGroupsSnapshot);
saveRoomGroupsCache(sharedHostGroupsSnapshot);
liveGroupsSyncHostNorm = hostNorm || '';
setRoomGroupsSyncActive(true);
syncSharedGroupsBridge();
const roomKey = getLobbyRoomSyncKey();
if (roomKey && sharedHostGroupsSnapshot.length && roomKey !== lastSharedGroupsJoinNoticeKey) {
lastSharedGroupsJoinNoticeKey = roomKey;
addGroupsLifecycleStatus('Room has synced groups...using host color groups');
}
renderGroupsPanel();
} catch (e) {
console.error('[TBC] Failed to decode chunked host groups payload', e);
} finally {
groupsSyncChunksBySession.delete(session);
}
return true;
}
const m = String(msgText || '').trim().match(/^\[TBCGREF\|([A-Za-z0-9+/=]+)\]$/);
if (!m) return false;
const hostNorm = getLobbyHostNameNorm();
const senderNorm = normalizeName(senderName);
if (!hostNorm || senderNorm !== hostNorm) return false;
try {
const payload = m[1];
sharedHostGroupsSnapshot = decodeSharedGroupsPayload(payload, sharedHostGroupsSnapshot);
saveRoomGroupsCache(sharedHostGroupsSnapshot);
liveGroupsSyncHostNorm = hostNorm || '';
setRoomGroupsSyncActive(true);
syncSharedGroupsBridge();
const roomKey = getLobbyRoomSyncKey();
if (roomKey && sharedHostGroupsSnapshot.length && roomKey !== lastSharedGroupsJoinNoticeKey) {
lastSharedGroupsJoinNoticeKey = roomKey;
addGroupsLifecycleStatus('Room has synced groups...using host color groups');
}
renderGroupsPanel();
} catch (e) {
console.error('[TBC] Failed to decode host groups payload', e);
}
return true;
}
const GROUPS_SYNC_CHAT_CARRIER_VISIBLE = '.';
const GROUPS_SYNC_CHAT_CARRIER_MARKER = '\u2063\u2064';
function encodeGroupsSyncCarrierPayload(rawText) {
const src = String(rawText || '');
let bytes = null;
try {
if (typeof TextEncoder !== 'undefined') bytes = Array.from(new TextEncoder().encode(src));
} catch {}
if (!bytes) {
const legacy = unescape(encodeURIComponent(src));
bytes = Array.from(legacy).map((ch) => ch.charCodeAt(0) & 0xff);
}
let out = '';
for (let i = 0; i < bytes.length; i += 1) {
const b = bytes[i] & 0xff;
out += String.fromCharCode(0xFE00 + ((b >> 4) & 0x0f));
out += String.fromCharCode(0xFE00 + (b & 0x0f));
}
return out;
}
function decodeGroupsSyncCarrierPayload(encodedText) {
const src = String(encodedText || '');
if (!src || (src.length % 2) !== 0) return null;
const bytes = [];
for (let i = 0; i < src.length; i += 2) {
const hi = src.charCodeAt(i) - 0xFE00;
const lo = src.charCodeAt(i + 1) - 0xFE00;
if (hi < 0 || hi > 15 || lo < 0 || lo > 15) return null;
bytes.push((hi << 4) | lo);
}
try {
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(new Uint8Array(bytes));
} catch {}
try {
return decodeURIComponent(escape(String.fromCharCode(...bytes)));
} catch {
return null;
}
}
function buildGroupsSyncTransportMessage(rawMessage) {
return String(rawMessage || '').trim();
}
function buildGroupsSyncTransportCarrierMessage(rawMessage) {
const payload = encodeGroupsSyncCarrierPayload(String(rawMessage || ''));
return `${GROUPS_SYNC_CHAT_CARRIER_VISIBLE}${GROUPS_SYNC_CHAT_CARRIER_MARKER}${payload}`;
}
function parseGroupsSyncTransportMessage(msgText) {
const raw = String(msgText || '').trim();
if (/^\[(TBCGREF\||TBCGCLR|TBCGRELAY\||TBCGRELAY\])/i.test(raw)) return raw;
if (!raw.startsWith(GROUPS_SYNC_CHAT_CARRIER_VISIBLE + GROUPS_SYNC_CHAT_CARRIER_MARKER)) return null;
const encoded = raw.slice((GROUPS_SYNC_CHAT_CARRIER_VISIBLE + GROUPS_SYNC_CHAT_CARRIER_MARKER).length);
const decoded = decodeGroupsSyncCarrierPayload(encoded);
if (!decoded || !/^\[(TBCGREF\||TBCGCLR|TBCGRELAY\||TBCGRELAY\])/i.test(decoded)) return null;
return decoded;
}
function getChatAccountIdentity() {
const lvlEl = $('pretty_top_level');
const nameEl = $('pretty_top_name');
if (!lvlEl || !nameEl) return null;
const lvl = String(lvlEl.textContent || '').trim().toLowerCase();
const nameRaw = String(nameEl.textContent || '').trim();
const name = nameRaw.toLowerCase();
if (!lvl || lvl === 'guest') return null;
if (!name || name === 'guest') return null;
const looksLoggedIn = /^level\b/.test(lvl) || /\d/.test(lvl);
if (!looksLoggedIn) return null;
return { level: lvl, name };
}
function getChatAccountNameOrNull() {
const ident = getChatAccountIdentity();
return ident ? ident.name : null;
}
function getChatStorageKeyV2() {
const acct = getChatAccountNameOrNull();
if (!acct) return null;
return CHAT_STORAGE_PREFIX_V2 + acct;
}
function loadChatState() {
try {
chatState = createDefaultChatState();
if (!chatStorageKey) return;
const raw = localStorage.getItem(chatStorageKey);
if (!raw) return;
const data = JSON.parse(raw);
if (!data || typeof data !== 'object') return;
chatState.hideGuests = !!data.hideGuests;
chatState.showSystemMessages = data.showSystemMessages === true;
chatState.useCustomSystemMessageColors = data.useCustomSystemMessageColors === true;
chatState.ingameChatBackgrounds = data.ingameChatBackgrounds === true;
chatState.hideIngameOthersUntilFadeDelay = data.hideIngameOthersUntilFadeDelay === true;
chatState.blacklistUsers = Array.isArray(data.blacklistUsers) ? data.blacklistUsers.map(s => String(s || '').trim()).filter(Boolean) : [];
chatState.systemMessageColors = normalizeSystemMessageColors(data.systemMessageColors);
chatState.ingameChatLines = clampIngameChatLines(
data.ingameChatLines === undefined ? chatState.ingameChatLines : data.ingameChatLines
);
chatState.ingameFadeDelaySec = clampIngameFadeDelaySec(
data.ingameFadeDelaySec === undefined ? chatState.ingameFadeDelaySec : data.ingameFadeDelaySec
);
} catch (e) {
console.error('[TBC Chat] loadChatState failed', e);
}
}
function normalizeSystemMessageColors(data) {
const defaults = createDefaultSystemMessageColors();
if (!data || typeof data !== 'object') return defaults;
const legacyKickBanHex = parseColorTextToHex(
data.kickBan && data.kickBan.hex ? data.kickBan.hex : '',
data.kickBan && data.kickBan.format ? data.kickBan.format : 'hex'
);
const legacyKickBanFormat =
data.kickBan && typeof data.kickBan.format === 'string' && SYSTEM_COLOR_NEXT_FORMAT[data.kickBan.format]
? data.kickBan.format
: 'hex';
SYSTEM_COLOR_CATEGORIES.forEach((cat) => {
const raw = data[cat.id];
const fallbackRaw =
(cat.id === 'kick' || cat.id === 'ban') && !raw && legacyKickBanHex
? { hex: legacyKickBanHex, format: legacyKickBanFormat }
: raw;
const parsedHex = parseColorTextToHex(
fallbackRaw && fallbackRaw.hex ? fallbackRaw.hex : '',
fallbackRaw && fallbackRaw.format ? fallbackRaw.format : 'hex'
);
defaults[cat.id] = {
hex: (parsedHex || SYSTEM_COLOR_DEFAULT_HEX[cat.id] || SYSTEM_COLOR_DEFAULT_HEX.defaultSystem).toLowerCase(),
format:
fallbackRaw && typeof fallbackRaw.format === 'string' && SYSTEM_COLOR_NEXT_FORMAT[fallbackRaw.format]
? fallbackRaw.format
: 'hex',
};
});
return defaults;
}
function saveChatState() {
try {
updateChatStorageKey();
if (!chatStorageKey) {
window.dispatchEvent(new Event('tbcChatSettingsChanged'));
updateChatStorageHintUI();
scheduleChatScan();
return;
}
localStorage.setItem(chatStorageKey, JSON.stringify(chatState));
window.dispatchEvent(new Event('tbcChatSettingsChanged'));
updateChatStorageHintUI();
scheduleChatScan();
} catch (e) {
console.error('[TBC Chat] saveChatState failed', e);
}
}
function mutateChatState(mutator) {
updateChatStorageKey(true);
try {
mutator(chatState);
} catch (e) {
console.error('[TBC Chat] mutateChatState failed', e);
return;
}
saveChatState();
}
function updateChatStorageKey(force = false) {
const ident = getChatAccountIdentity();
const identityKey = ident ? `${ident.level}|${ident.name}` : 'guest';
const k = ident ? (CHAT_STORAGE_PREFIX_V2 + ident.name) : null;
if (!force && k === lastChatStorageKey && identityKey === lastChatIdentity) return;
lastChatIdentity = identityKey;
lastChatStorageKey = k;
chatStorageKey = k;
loadChatState();
updateChatStorageHintUI();
if (typeof refreshChatSettingsUi === 'function') refreshChatSettingsUi();
applyIngameChatBackgroundSetting();
applyIngameChatLines();
if (chatState.showSystemMessages) restoreStashedIngameSystemRows();
scheduleChatScan();
}
function ensureChatAccountWatchers() {
if (chatAccountObserver) return;
chatAccountObserver = new MutationObserver(() => updateChatStorageKey());
waitForElement('pretty_top_name', (el) => {
if (chatAccountObserver) chatAccountObserver.observe(el, { childList: true, subtree: true, characterData: true });
});
waitForElement('pretty_top_level', (el) => {
if (chatAccountObserver) chatAccountObserver.observe(el, { childList: true, subtree: true, characterData: true });
});
}
function updateChatStorageHintUI() {
const el = $('tbc_chat_storage_hint');
if (!el) return;
if (chatStorageKey) {
el.style.color = '';
el.style.opacity = '0.75';
el.textContent = `Per-account storage: ${chatStorageKey}`;
} else {
el.style.color = '#ffcc66';
el.style.opacity = '0.9';
el.textContent = 'Guest mode: settings are temporary until you log in.';
}
}
function clampIngameChatLines(v) {
const n = parseInt(String(v), 10);
if (!Number.isFinite(n)) return 4;
return Math.max(1, Math.min(10, n));
}
function clampIngameFadeDelaySec(v) {
const n = parseFloat(String(v));
if (!Number.isFinite(n)) return 8;
return Math.max(1, Math.min(60, n));
}
function clamp255(v) {
const n = parseFloat(String(v));
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(255, Math.round(n)));
}
function clamp01(v) {
const n = parseFloat(String(v));
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(1, n));
}
function normalizeHexColor(input) {
let s = String(input || '').trim().toLowerCase();
if (!s) return null;
if (s.charAt(0) !== '#') s = `#${s}`;
if (/^#[0-9a-f]{3}$/.test(s)) {
const a = s.charAt(1), b = s.charAt(2), c = s.charAt(3);
return `#${a}${a}${b}${b}${c}${c}`;
}
if (/^#[0-9a-f]{6}$/.test(s)) return s;
return null;
}
function hexToRgbObj(hex) {
const h = normalizeHexColor(hex);
if (!h) return null;
return {
r: parseInt(h.slice(1, 3), 16),
g: parseInt(h.slice(3, 5), 16),
b: parseInt(h.slice(5, 7), 16),
};
}
function rgbObjToHex(r, g, b) {
const rr = clamp255(r).toString(16).padStart(2, '0');
const gg = clamp255(g).toString(16).padStart(2, '0');
const bb = clamp255(b).toString(16).padStart(2, '0');
return `#${rr}${gg}${bb}`;
}
function rgbToHsvObj(r, g, b) {
const rn = clamp255(r) / 255;
const gn = clamp255(g) / 255;
const bn = clamp255(b) / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === rn) h = ((gn - bn) / d) % 6;
else if (max === gn) h = ((bn - rn) / d) + 2;
else h = ((rn - gn) / d) + 4;
h *= 60;
if (h < 0) h += 360;
}
const s = max === 0 ? 0 : d / max;
const v = max;
return { h, s, v };
}
function hsvToRgbObj(h, s, v) {
let hh = parseFloat(String(h));
if (!Number.isFinite(hh)) hh = 0;
hh = ((hh % 360) + 360) % 360;
const ss = clamp01(s);
const vv = clamp01(v);
const c = vv * ss;
const x = c * (1 - Math.abs(((hh / 60) % 2) - 1));
const m = vv - c;
let rp = 0, gp = 0, bp = 0;
if (hh < 60) { rp = c; gp = x; bp = 0; }
else if (hh < 120) { rp = x; gp = c; bp = 0; }
else if (hh < 180) { rp = 0; gp = c; bp = x; }
else if (hh < 240) { rp = 0; gp = x; bp = c; }
else if (hh < 300) { rp = x; gp = 0; bp = c; }
else { rp = c; gp = 0; bp = x; }
return {
r: clamp255((rp + m) * 255),
g: clamp255((gp + m) * 255),
b: clamp255((bp + m) * 255),
};
}
function parseRgbText(text) {
const nums = String(text || '').match(/-?\d+(\.\d+)?/g);
if (!nums || nums.length < 3) return null;
return { r: clamp255(nums[0]), g: clamp255(nums[1]), b: clamp255(nums[2]) };
}
function parseHsvText(text) {
const nums = String(text || '').match(/-?\d+(\.\d+)?/g);
if (!nums || nums.length < 3) return null;
const h = parseFloat(nums[0]);
const sRaw = parseFloat(nums[1]);
const vRaw = parseFloat(nums[2]);
if (!Number.isFinite(h) || !Number.isFinite(sRaw) || !Number.isFinite(vRaw)) return null;
const s = clamp01(sRaw > 1 ? (sRaw / 100) : sRaw);
const v = clamp01(vRaw > 1 ? (vRaw / 100) : vRaw);
return { h, s, v };
}
function parseColorTextToHex(text, format) {
const fmt = String(format || 'hex').toLowerCase();
if (fmt === 'hex') return normalizeHexColor(text);
if (fmt === 'rgb') {
const rgb = parseRgbText(text);
return rgb ? rgbObjToHex(rgb.r, rgb.g, rgb.b) : null;
}
if (fmt === 'hsv') {
const hsv = parseHsvText(text);
if (!hsv) return null;
const rgb = hsvToRgbObj(hsv.h, hsv.s, hsv.v);
return rgbObjToHex(rgb.r, rgb.g, rgb.b);
}
return normalizeHexColor(text);
}
function formatHexByMode(hex, format) {
const h = normalizeHexColor(hex) || '#000000';
const fmt = String(format || 'hex').toLowerCase();
if (fmt === 'hex') return h;
const rgb = hexToRgbObj(h) || { r: 0, g: 0, b: 0 };
if (fmt === 'rgb') return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
const hsv = rgbToHsvObj(rgb.r, rgb.g, rgb.b);
return `hsv(${Math.round(hsv.h)}, ${Math.round(hsv.s * 100)}%, ${Math.round(hsv.v * 100)}%)`;
}
function classifySystemMessageCategory(text) {
const raw = String(text || '');
const s = normalizeStatusText(raw).replace(/^\*\s*/, '');
if (!s) return 'defaultSystem';
if (isReplayRecorderSystemText(s)) return 'replay';
if (s.indexOf('portal') !== -1) return 'portal';
if (
s.indexOf('friend request') !== -1 ||
s.indexOf('now friends') !== -1
) return 'friend';
if (
/\bhas given host privileges to\b/.test(s) ||
/\bwho is now the game host\b/.test(s) ||
/\byou are now the host of this game\b/.test(s) ||
/\byou are now the game host\b/.test(s) ||
/\bleft the game and .+ is now the game host\b/.test(s)
) return 'hostTransfer';
if (/\bhas joined the game\b/.test(s)) return 'userJoin';
if (
/\bhas left the game\b/.test(s)
) return 'userLeft';
if (/^\/[a-z]/.test(s)) {
if (
s.indexOf('/mapimg') !== -1 ||
s.indexOf('/copymap') !== -1 ||
s.indexOf('/groups') !== -1 ||
s.indexOf('/panels') !== -1 ||
s.indexOf('/points') !== -1 ||
s.indexOf('/groupssync') !== -1 ||
s.indexOf('/groupsrelay') !== -1 ||
s.indexOf('/blacklist') !== -1
) return 'customCommands';
return 'helpHint';
}
if (/^(?:\*\s*)?banned\b/.test(s)) return 'ban';
if (/^(?:\*\s*)?kicked\b/.test(s)) return 'kick';
if (
s.indexOf('not recognised') !== -1 ||
s.indexOf('accepted commands are listed above') !== -1 ||
s.indexOf('accepted commands listed above') !== -1
) return 'helpHint';
if (
s.indexOf('/mapimg') !== -1 ||
s.indexOf('/copymap') !== -1 ||
s.indexOf('/groups') !== -1 ||
s.indexOf('/panels') !== -1 ||
s.indexOf('/points') !== -1 ||
s.indexOf('/groupssync') !== -1 ||
s.indexOf('/groupsrelay') !== -1 ||
s.indexOf('/blacklist') !== -1 ||
s.indexOf('[tbc]') !== -1 ||
s.indexOf('shared groups panel') !== -1 ||
s.indexOf('groups panel refresh sent') !== -1
) return 'customCommands';
return 'defaultSystem';
}
function getSystemCategoryColorHex(categoryId) {
const id = String(categoryId || 'defaultSystem');
const map = chatState && chatState.systemMessageColors ? chatState.systemMessageColors : null;
const hex = map && map[id] ? map[id].hex : null;
return normalizeHexColor(hex) || SYSTEM_COLOR_DEFAULT_HEX[id] || SYSTEM_COLOR_DEFAULT_HEX.defaultSystem;
}
function getSystemColorForText(text) {
const category = classifySystemMessageCategory(text);
return {
category,
color: getSystemCategoryColorHex(category),
};
}
function resolveIngameRow(node) {
const row = resolveChatRow(node);
if (!row || !row.isConnected) return null;
const isInGame =
(row.classList && row.classList.contains('ingamechatentry')) ||
!!row.closest('#ingamechatcontent');
return isInGame ? row : null;
}
function markIngameRowFadeBirth(node, force = false) {
const row = resolveIngameRow(node);
if (!row) return;
if (!force && row.dataset.tbcFadeBornAt) return;
row.dataset.tbcFadeBornAt = String(Date.now());
delete row.dataset.tbcFadeBypassDelay;
row.dataset.tbcFadeSig = String(row.textContent || '').trim();
}
function getVisibleIngameRowsForFade(host) {
if (!host) return [];
const out = [];
const seen = new Set();
const direct = Array.from(host.children || []);
for (const child of direct) {
const row = resolveIngameRow(child) || child;
if (!(row instanceof Element)) continue;
if (seen.has(row)) continue;
seen.add(row);
if (row.classList && row.classList.contains('tbc_chat_invisible')) continue;
if (row.classList && row.classList.contains('tbc_fade_hidden')) continue;
if ((row.offsetHeight || 0) <= 0 && (row.getClientRects ? row.getClientRects().length : 0) <= 0) continue;
out.push(row);
}
return out;
}
function applyIngameRowOpacity(row, opacity) {
if (!row || !(row instanceof Element)) return;
const o = Math.max(0, Math.min(1, opacity));
const prev = parseFloat(row.dataset.tbcLastOpacity || '');
if (!Number.isFinite(prev) || Math.abs(prev - o) > 0.01) {
row.style.opacity = String(o);
row.dataset.tbcLastOpacity = String(o);
}
if (row.style.transition !== 'opacity 140ms linear') row.style.transition = 'opacity 140ms linear';
}
function setIngameRowFadeHidden(row, hidden) {
if (!row || !(row instanceof Element)) return;
row.classList.toggle('tbc_fade_hidden', !!hidden);
}
function resetIngameFadeRowsToVisible(host) {
if (!host) return;
const seen = new Set();
const direct = Array.from(host.children || []);
for (const child of direct) {
const row = resolveIngameRow(child) || child;
if (!(row instanceof Element)) continue;
if (seen.has(row)) continue;
seen.add(row);
setIngameRowFadeHidden(row, false);
applyIngameRowOpacity(row, 1);
}
}
function isIngameChatInteracting() {
const input = $('ingamechatinputtext');
if (!input) return false;
if (document.activeElement !== input) return false;
const cs = window.getComputedStyle(input);
if (!cs) return false;
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
if (
(input.offsetWidth || 0) <= 0 &&
(input.offsetHeight || 0) <= 0 &&
(input.getClientRects ? input.getClientRects().length : 0) <= 0
) {
return false;
}
return true;
}
function updateIngameChatScrollFocusState(host, focused) {
if (!host) return;
const wasFocused = host.classList.contains('tbc_ingame_scroll_focus');
const prevBottomOffset = host.scrollHeight - host.scrollTop;
host.classList.toggle('tbc_ingame_scroll_focus', !!focused);
if (focused && !wasFocused) {
host.scrollTop = host.scrollHeight;
requestAnimationFrame(() => {
host.scrollTop = host.scrollHeight;
requestAnimationFrame(() => {
host.scrollTop = host.scrollHeight;
});
});
return;
}
const nextTop = host.scrollHeight - prevBottomOffset;
if (Number.isFinite(nextTop)) host.scrollTop = Math.max(0, nextTop);
}
function syncIngameChatScrollbarState(host) {
if (!host) return;
const focused = host.classList.contains('tbc_ingame_scroll_focus');
const effectiveLines = Math.max(1, parseInt(host.getAttribute('data-tbc-effective-lines') || '1', 10) || 1);
const visibleRows = getVisibleIngameRowsForFade(host).length;
const hasOverflow = visibleRows > effectiveLines;
const showScrollbar = focused && hasOverflow;
host.classList.toggle('tbc_ingame_has_scrollbar', showScrollbar);
host.style.overflowY = focused ? (showScrollbar ? 'scroll' : 'hidden') : 'hidden';
}
function applyIngameChatFade() {
const host = $('ingamechatcontent');
if (!host) return;
if (!isElementActuallyVisible(host) && !isIngameChatInteracting()) return;
const now = Date.now();
const liveInteracting = isIngameChatInteracting();
updateIngameChatScrollFocusState(host, liveInteracting);
const delayMs = Math.round(clampIngameFadeDelaySec(chatState.ingameFadeDelaySec) * 1000);
const fadeMs = 1400;
const selfNorm = getSelfNameNorm();
const hideOthersUntilDelay = !!chatState.hideIngameOthersUntilFadeDelay;
if (liveInteracting) {
resetIngameFadeRowsToVisible(host);
const visibleWhileFocused = getVisibleIngameRowsForFade(host);
visibleWhileFocused.forEach((row) => {
row.dataset.tbcFadeKeepBornAtOnNextSigRefresh = '1';
});
syncIngameChatScrollbarState(host);
wasIngameChatInteracting = true;
return;
}
if (!chatState.showSystemMessages && wasIngameChatInteracting) pruneHiddenIngameSystemRows();
const visible = getVisibleIngameRowsForFade(host);
if (wasIngameChatInteracting) {
visible.forEach((row) => {
let bornAt = parseFloat(row.dataset.tbcFadeBornAt || '');
if (!Number.isFinite(bornAt) || bornAt <= 0) {
bornAt = now;
row.dataset.tbcFadeBornAt = String(bornAt);
}
const age = now - bornAt;
if (age >= delayMs) row.dataset.tbcFadeBypassDelay = '1';
else delete row.dataset.tbcFadeBypassDelay;
row.dataset.tbcFadeSig = String(row.textContent || '').trim();
setIngameRowFadeHidden(row, false);
applyIngameRowOpacity(row, 1);
});
}
wasIngameChatInteracting = false;
for (let i = 0; i < visible.length; i++) {
const row = visible[i];
const curSig = String(row.textContent || '').trim();
if (row.dataset.tbcFadeSig !== curSig) {
const keepBornAt = row.dataset.tbcFadeKeepBornAtOnNextSigRefresh === '1';
row.dataset.tbcFadeSig = curSig;
if (keepBornAt) {
delete row.dataset.tbcFadeKeepBornAtOnNextSigRefresh;
} else {
row.dataset.tbcFadeBornAt = String(now);
delete row.dataset.tbcFadeBypassDelay;
}
}
let bornAt = parseFloat(row.dataset.tbcFadeBornAt || '');
if (!Number.isFinite(bornAt) || bornAt <= 0) {
bornAt = now;
row.dataset.tbcFadeBornAt = String(bornAt);
row.dataset.tbcFadeSig = curSig;
}
let age = now - bornAt;
if (row.dataset.tbcFadeBypassDelay === '1') age += delayMs;
if (hideOthersUntilDelay) {
const msgType = String(row.dataset.tbcMsgType || '').toLowerCase();
if (msgType === 'user') {
const parts = getInGameMessagePartsFromRow(row);
const senderNorm = normalizeName(parts.sender);
const isSelfMsg = !!selfNorm && !!senderNorm && senderNorm === selfNorm;
if (!isSelfMsg && age < delayMs) {
setIngameRowFadeHidden(row, true);
applyIngameRowOpacity(row, 0);
continue;
}
}
}
let opacity = 1;
if (age > delayMs) {
opacity = 1 - ((age - delayMs) / fadeMs);
}
if (opacity <= 0.01) {
setIngameRowFadeHidden(row, true);
applyIngameRowOpacity(row, 0);
continue;
}
setIngameRowFadeHidden(row, false);
applyIngameRowOpacity(row, opacity);
}
}
function scheduleIngameVisualRefresh() {
if (ingameVisualRefreshQueued) return;
ingameVisualRefreshQueued = true;
requestAnimationFrame(() => {
ingameVisualRefreshQueued = false;
applyIngameChatLines();
applyIngameChatFade();
});
}
function applyIngameChatBackgroundSetting() {
const host = $('ingamechatcontent');
if (!host) return;
host.classList.toggle('tbc_ingame_bg_on', !!chatState.ingameChatBackgrounds);
}
function applyIngameChatLines() {
const host = $('ingamechatcontent');
const chatBox = $('ingamechatbox');
if (!host) return;
const lines = clampIngameChatLines(chatState.ingameChatLines);
const defaultLines = 4;
function readPositivePx(el, attrName) {
const v = parseFloat(el.getAttribute(attrName) || '');
return Number.isFinite(v) && v > 0 ? v : 0;
}
let perLine =
readPositivePx(host, 'data-tbc-per-line-height-px') ||
readPositivePx(host, 'data-tbc-runtime-per-line-height-px');
let hostBase =
readPositivePx(host, 'data-tbc-base-chat-height-px') ||
readPositivePx(host, 'data-tbc-runtime-base-chat-height-px');
if (!perLine) {
const hostLineH = parseFloat(window.getComputedStyle(host).lineHeight);
if (Number.isFinite(hostLineH) && hostLineH > 0) perLine = hostLineH;
else if (hostBase) perLine = hostBase / defaultLines;
else perLine = 18;
host.setAttribute('data-tbc-runtime-per-line-height-px', String(perLine));
}
if (!Number.isFinite(hostBase) || hostBase <= 0) {
hostBase = perLine * defaultLines;
host.setAttribute('data-tbc-runtime-base-chat-height-px', String(hostBase));
}
let effectiveLines = lines;
if (host.classList.contains('tbc_ingame_scroll_focus')) {
const rowCount = Array.from(host.children || []).filter((row) => {
if (!(row instanceof Element)) return false;
const msgType = String(row.dataset.tbcMsgType || '').toLowerCase();
const isTaggedSystem = msgType === 'system';
const isTaggedBlacklisted = row.dataset.tbcMsgBlacklisted === '1';
if (isTaggedSystem && !chatState.showSystemMessages) return false;
if (isTaggedBlacklisted) return false;
if (row.classList.contains('tbc_chat_invisible')) return false;
return true;
}).length;
if (rowCount > 0) effectiveLines = Math.min(lines, rowCount);
}
const hostPx = Math.max(Math.ceil(perLine), Math.round(perLine * effectiveLines));
host.setAttribute('data-tbc-effective-lines', String(effectiveLines));
host.style.height = `${hostPx}px`;
host.style.maxHeight = `${hostPx}px`;
if (chatBox) {
let chromePx = readPositivePx(chatBox, 'data-tbc-runtime-chat-chrome-px');
if (!chromePx) {
const boxNow =
parseFloat(window.getComputedStyle(chatBox).height) || chatBox.clientHeight || chatBox.offsetHeight || 0;
const hostNow =
parseFloat(window.getComputedStyle(host).height) || host.clientHeight || host.offsetHeight || 0;
const attrBoxBase = readPositivePx(chatBox, 'data-tbc-base-chat-height-px');
const attrHostBase = readPositivePx(host, 'data-tbc-base-chat-height-px');
const attrDiff = attrBoxBase > 0 && attrHostBase > 0 ? (attrBoxBase - attrHostBase) : 0;
chromePx = Math.max(16, boxNow - hostNow, attrDiff || 0, 24);
chatBox.setAttribute('data-tbc-runtime-chat-chrome-px', String(chromePx));
}
const boxPx = Math.max(24, Math.floor(hostPx + chromePx));
chatBox.style.height = `${boxPx}px`;
chatBox.style.maxHeight = `${boxPx}px`;
}
const wasNearBottom = isContainerExactlyAtBottom(host);
syncIngameChatScrollbarState(host);
if (wasNearBottom) host.scrollTop = host.scrollHeight;
}
function ensureChatStyles() {
if ($('tbc_chat_css')) return;
const style = document.createElement('style');
style.id = 'tbc_chat_css';
style.textContent = `
.tbc_row { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-top:8px; }
.tbc_toggle {
display:inline-flex; align-items:center; gap:8px;
padding: 6px 10px; border-radius: 10px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.16);
cursor:pointer; font-size: 11px; opacity: .95;
user-select:none;
}
.tbc_toggle:hover { background: rgba(255,255,255,0.08); }
.tbc_toggle_dot {
width: 30px; height: 16px; border-radius: 999px;
border: 1px solid rgba(255,255,255,0.16);
background: rgba(0,0,0,0.25);
position: relative; flex: 0 0 auto;
}
.tbc_toggle_dot::after{
content:""; position:absolute; top:50%; left:1px;
width: 12px; height: 12px; border-radius: 999px;
background: rgba(255,255,255,0.75);
transform: translateY(-50%);
transition: transform 0.15s;
}
.tbc_toggle.on .tbc_toggle_dot {
background: rgba(121,85,248,0.35);
border-color: rgba(121,85,248,0.55);
}
.tbc_toggle.on .tbc_toggle_dot::after { transform: translate(14px, -50%); }
.tbc_box {
margin-top: 10px;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.14);
}
.tbc_h { font-weight: 800; font-size: 12px; margin-bottom: 6px; }
.tbc_p { font-size: 11px; opacity: .88; line-height: 1.35; margin-bottom: 6px; }
.tbc_inputrow { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.tbc_input {
width: 220px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.22);
color: #fff;
padding: 6px 8px;
font-size: 12px;
outline: none;
}
.tbc_no_spin {
appearance: textfield;
-moz-appearance: textfield;
}
.tbc_no_spin::-webkit-outer-spin-button,
.tbc_no_spin::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.tbc_btn {
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
cursor:pointer;
font-size: 11px;
user-select:none;
}
.tbc_btn:hover { background: rgba(255,255,255,0.12); }
.tbc_syscolor_head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.tbc_syscolor_reset {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.88);
font-size: 12px;
line-height: 1;
cursor: pointer;
user-select: none;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.tbc_syscolor_reset:hover {
background: rgba(255, 76, 76, 0.16);
border-color: rgba(255, 76, 76, 0.8);
color: #ff4c4c;
}
.tbc_chips { display:flex; flex-wrap:wrap; gap:6px; margin-top: 8px; }
.tbc_chip {
display:inline-flex; align-items:center; gap:8px;
padding: 4px 8px; border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.18);
font-size: 11px; opacity: .95;
}
.tbc_chip_x { cursor:pointer; opacity:.8; padding: 0 2px; }
.tbc_chip_x:hover { opacity: 1; }
.tbc_chat_hidden_notice{
display: block;
margin: 2px 0 !important;
padding: 2px 4px !important;
border-radius: 6px;
background: rgba(0,0,0,0.16);
border: 1px solid rgba(255,255,255,0.10);
opacity: 0.88;
}
.tbc_chat_hidden_notice a {
color: inherit;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
.tbc_chat_original{
display: block;
}
.tbc_chat_invisible {
display: none !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
opacity: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}
.tbc_fade_hidden {
display: none !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
opacity: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}
.tbc_chat_hidectl a{
font-weight: 700;
}
#tbc_groups_panel{
position: fixed;
z-index: 30;
box-sizing: border-box;
pointer-events: auto;
}
#adboxverticalleftCurse,
#adboxverticalCurse,
#adboxverticalleft,
#adboxvertical {
pointer-events: none !important;
}
.tbc_groups_window{
height: 100%;
display: flex;
flex-direction: column;
border-radius: 6px;
overflow: hidden;
}
.tbc_groups_top{
height: 30px;
display:flex;
align-items:center;
gap:8px;
padding: 0 10px;
color: #fff;
background: rgb(0, 160, 153);
border-bottom: 1px solid rgba(0,0,0,0.25);
flex: 0 0 auto;
user-select: none;
touch-action: none;
cursor: grab;
}
#tbc_groups_panel.tbc_groups_panel_dragging .tbc_groups_top{
cursor: grabbing;
}
.tbc_groups_drag_grip{
width: 14px;
height: 14px;
display:grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 1px;
align-items: center;
justify-items: center;
flex: 0 0 auto;
}
.tbc_groups_drag_grip_dot{
display:block;
width: 2px;
height: 2px;
border-radius: 999px;
background: rgba(255,255,255,0.95);
}
.tbc_groups_top_title{
font-size: 13px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.tbc_groups_close_btn{
margin-left:auto;
flex: 0 0 auto;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
line-height: 20px;
text-align: center;
border-radius: 50%;
border: 0;
color: #fff;
background: #7f6151;
box-shadow: 0 2px 4px rgba(0,0,0,0.35);
cursor: pointer;
font-weight: 700;
padding: 0;
}
.tbc_groups_body{
background: #d4dde2;
color: #1f2a33;
padding: 8px;
overflow-y: auto;
overflow-x: hidden;
flex: 1 1 auto;
box-sizing: border-box;
}
.tbc_groups_head{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:8px;
}
.tbc_groups_sub{ font-size:11px; opacity:.9; margin-top:2px; font-weight:700; }
.tbc_groups_actions{ display:flex; gap:6px; }
.tbc_groups_btn{
border: 1px solid rgba(0,0,0,0.25);
background: rgb(127, 97, 81);
color:#fff;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
}
.tbc_groups_btn:disabled{
opacity: .5;
cursor: default;
}
.tbc_groups_hint{ margin-top:6px; font-size:11px; opacity:.8; }
.tbc_groups_list{ margin-top:8px; display:flex; flex-direction:column; gap:7px; }
.tbc_groups_card{
border: 1px solid rgba(0,0,0,0.16);
border-radius: 6px;
padding: 6px;
background: rgba(255,255,255,0.55);
}
.tbc_groups_card_top{
display:flex;
align-items:center;
gap:7px;
font-size:12px;
}
.tbc_groups_dot{
width:10px;
height:10px;
border-radius:999px;
border:1px solid rgba(255,255,255,0.25);
flex:0 0 auto;
}
.tbc_groups_name{ font-weight:700; }
.tbc_groups_group_btn{
margin-left:auto;
width:20px;
height:20px;
border:1px solid rgba(0,0,0,0.25);
border-radius:50%;
background: transparent;
color:#1f2a33;
font-size:12px;
line-height:1;
cursor:pointer;
padding:0;
opacity:1;
pointer-events:auto;
transition: opacity 0.12s ease;
display:flex;
align-items:center;
justify-content:center;
}
.tbc_groups_group_btn:hover{
background: rgba(24, 43, 58, 0.16);
}
.tbc_groups_group_btn:disabled{
opacity:.9;
color: rgba(31,42,51,0.85);
border-color: rgba(0,0,0,0.35);
pointer-events:none;
}
.tbc_groups_count{
opacity:.8;
font-size:10px;
border:1px solid rgba(0,0,0,0.18);
border-radius:999px;
padding:1px 6px;
}
.tbc_groups_players{
display:grid;
grid-template-columns: 1fr 1fr;
gap:6px;
margin-top:6px;
min-width: 0;
}
.tbc_groups_player{
position: relative;
min-height: 26px;
border:1px solid rgba(0,0,0,0.18);
border-radius:6px;
background: rgba(255,255,255,0.75);
padding:4px 6px;
display:flex;
align-items:center;
gap: 6px;
box-sizing:border-box;
min-width: 0;
}
.tbc_groups_player.has-actions{
cursor: pointer;
}
.tbc_groups_player_name{
font-size:10px;
font-weight:600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display:inline-flex;
align-items:center;
gap:4px;
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
}
.tbc_groups_player.has-actions .tbc_groups_player_name{
padding-right:0;
}
.tbc_groups_player_actions{
position:static;
margin-left:auto;
display:flex;
gap:3px;
opacity:0;
pointer-events:none;
transition: opacity 0.12s ease;
flex: 0 0 auto;
z-index: 2;
}
.tbc_groups_player.has-actions .tbc_groups_player_actions{
opacity:1;
pointer-events:auto;
}
.tbc_groups_player:hover .tbc_groups_player_actions,
.tbc_groups_player:focus-within .tbc_groups_player_actions{
opacity:1;
pointer-events:auto;
}
.tbc_groups_player_btn{
width:20px;
height:20px;
border:1px solid rgba(0,0,0,0.25);
border-radius:50%;
background: transparent;
color:#1f2a33;
font-size:12px;
line-height:1;
cursor:pointer;
padding:0;
display:flex;
align-items:center;
justify-content:center;
}
.tbc_groups_player_btn:hover{
background: rgba(24, 43, 58, 0.16);
}
.tbc_groups_empty{ font-size:11px; opacity:.7; }
#tbc_points_panel{
position: fixed;
z-index: 30;
box-sizing: border-box;
pointer-events: auto;
}
.tbc_points_window{
height: 100%;
display: flex;
flex-direction: column;
border-radius: 6px;
overflow: hidden;
}
.tbc_points_top{
height: 30px;
display:flex;
align-items:center;
gap:8px;
padding: 0 10px;
color: #fff;
background: rgb(0, 160, 153);
border-bottom: 1px solid rgba(0,0,0,0.25);
flex: 0 0 auto;
user-select: none;
touch-action: none;
cursor: grab;
}
#tbc_points_panel.tbc_points_panel_dragging .tbc_points_top{
cursor: grabbing;
}
.tbc_points_drag_grip{
width: 14px;
height: 14px;
display:grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 1px;
align-items: center;
justify-items: center;
flex: 0 0 auto;
}
.tbc_points_drag_grip_dot{
display:block;
width: 2px;
height: 2px;
border-radius: 999px;
background: rgba(255,255,255,0.95);
}
.tbc_points_top_title{
font-size: 13px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.tbc_points_close_btn{
margin-left:auto;
flex: 0 0 auto;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
line-height: 20px;
text-align: center;
border-radius: 50%;
border: 0;
color: #fff;
background: #7f6151;
box-shadow: 0 2px 4px rgba(0,0,0,0.35);
cursor: pointer;
font-weight: 700;
padding: 0;
}
.tbc_points_body{
background: #d4dde2;
color: #1f2a33;
padding: 8px;
overflow-y: auto;
overflow-x: hidden;
flex: 1 1 auto;
box-sizing: border-box;
font-size: 11px;
}
.tbc_points_hint{
opacity: .85;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
}
.tbc_points_section{
border: 1px solid rgba(0,0,0,0.16);
border-radius: 6px;
background: rgba(255,255,255,0.55);
padding: 6px;
margin-bottom: 8px;
}
.tbc_points_section_title{
font-size: 12px;
font-weight: 700;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 6px;
}
.tbc_points_section_badge{
font-size: 10px;
opacity: .8;
border: 1px solid rgba(0,0,0,0.2);
border-radius: 999px;
padding: 1px 6px;
}
.tbc_points_list{
display: flex;
flex-direction: column;
gap: 5px;
}
.tbc_points_row{
display: grid;
grid-template-columns: 12px 1fr auto;
align-items: center;
gap: 6px;
border: 1px solid rgba(0,0,0,0.14);
border-radius: 6px;
background: rgba(255,255,255,0.78);
padding: 3px 6px;
}
.tbc_points_dot{
width: 10px;
height: 10px;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.18);
background: rgba(0,0,0,0.12);
}
.tbc_points_name{
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.tbc_points_score{
font-weight: 800;
font-size: 12px;
}
.tbc_points_warn{
border: 1px solid rgba(150, 35, 35, 0.45);
border-radius: 6px;
background: rgba(181, 48, 48, 0.16);
color: rgb(150, 35, 35);
padding: 8px;
font-size: 11px;
font-weight: 700;
}
.tbc_points_group_card{
border: 1px solid rgba(0,0,0,0.14);
border-radius: 6px;
background: rgba(255,255,255,0.68);
padding: 5px;
}
.tbc_points_contribs{
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 3px;
padding-left: 18px;
}
.tbc_points_contrib_row{
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
opacity: .92;
font-size: 10px;
}
.tbc_points_contrib_name{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.tbc_points_contrib_score{
font-weight: 700;
}
.tbc_points_empty{
font-size: 11px;
opacity: .75;
padding: 2px 0;
}
#ingamechatcontent {
display: flex;
flex-direction: column;
justify-content: flex-end;
--tbc-ingame-line-height: 19px;
line-height: var(--tbc-ingame-line-height);
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
box-sizing: border-box;
overflow-y: auto !important;
scrollbar-gutter: auto;
}
#ingamechatcontent.tbc_ingame_scroll_focus {
display: block;
justify-content: initial;
overflow-y: hidden !important;
padding-right: 0 !important;
}
#ingamechatcontent.tbc_ingame_scroll_focus.tbc_ingame_has_scrollbar {
overflow-y: scroll !important;
padding-right: 2px !important;
}
#ingamechatcontent.tbc_ingame_scroll_focus.tbc_ingame_has_scrollbar:not(.tbc_ingame_bg_on) {
scrollbar-color: rgba(210, 220, 235, 0.85) transparent;
}
#ingamechatcontent.tbc_ingame_scroll_focus.tbc_ingame_has_scrollbar:not(.tbc_ingame_bg_on)::-webkit-scrollbar {
width: 9px;
}
#ingamechatcontent.tbc_ingame_scroll_focus.tbc_ingame_has_scrollbar:not(.tbc_ingame_bg_on)::-webkit-scrollbar-track {
background: transparent;
border-radius: 999px;
}
#ingamechatcontent.tbc_ingame_scroll_focus.tbc_ingame_has_scrollbar:not(.tbc_ingame_bg_on)::-webkit-scrollbar-thumb {
background: rgba(210, 220, 235, 0.85);
border-radius: 999px;
border: 1px solid rgba(20, 30, 40, 0.35);
}
#ingamechatcontent.tbc_ingame_scroll_focus.tbc_ingame_has_scrollbar:not(.tbc_ingame_bg_on)::-webkit-scrollbar-thumb:hover {
background: rgba(230, 238, 248, 0.92);
}
#ingamechatcontent > * {
display: block;
flex: 0 0 auto;
width: 100%;
box-sizing: border-box;
margin: 0;
min-height: var(--tbc-ingame-line-height);
line-height: var(--tbc-ingame-line-height);
padding: 0 4px;
border-radius: 0;
background: transparent;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
overflow: hidden;
text-overflow: clip;
contain: paint;
}
#ingamechatcontent.tbc_ingame_bg_on > * {
background: rgba(0, 0, 0, 0.22);
}
#ingamechatcontent.tbc_ingame_scroll_focus.tbc_ingame_has_scrollbar > * {
width: calc(100% - 5px);
}
#ingamechatcontent > * * {
line-height: inherit !important;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
#ingamechatcontent .ingamechatname,
#ingamechatcontent .ingamechatmessage,
#ingamechatcontent .ingamechattext,
#ingamechatcontent .newbonklobby_chat_status {
text-shadow: none !important;
}
#ingamechatcontent .newbonklobby_chat_status,
#ingamechatcontent .ingamechatstatus {
display: inline;
white-space: normal !important;
overflow-wrap: anywhere;
word-break: break-word;
}
#ingamechatbox #ingamechatinputtext {
display: block;
width: 100% !important;
margin: 0 !important;
box-sizing: border-box;
}
`;
document.head.appendChild(style);
}
function extractNameFromChatNameSpan(span) {
const raw = (span && span.textContent ? span.textContent : '').trim();
return extractChatName(raw);
}
function getLobbyPlayerIdentityPairs() {
const pairs = [];
const seen = new Set();
const roots = [
$('newbonklobby_playerbox_elementcontainer'),
$('newbonklobby_playerbox_leftelementcontainer'),
$('newbonklobby_playerbox_rightelementcontainer'),
$('newbonklobby_specbox_elementcontainer'),
].filter(Boolean);
const pushPair = (row) => {
if (!row || !(row instanceof Element)) return;
const nameEl = row.querySelector('.newbonklobby_playerentry_name');
const levelEl = row.querySelector('.newbonklobby_playerentry_level');
if (!nameEl || !levelEl) return;
const nameText = String(nameEl.textContent || '').trim();
const levelTextRaw = String(levelEl.textContent || '').trim();
if (!nameText || !levelTextRaw) return;
const dedupeKey = `${normalizeName(nameText)}|${levelTextRaw.toLowerCase()}`;
if (seen.has(dedupeKey)) return;
seen.add(dedupeKey);
pairs.push({ nameText, levelTextRaw });
};
roots.forEach((root) => {
root.querySelectorAll('.newbonklobby_playerentry').forEach((row) => pushPair(row));
});
if (!pairs.length) {
document
.querySelectorAll('#newbonklobby_playerbox .newbonklobby_playerentry, #newbonklobby_specbox .newbonklobby_playerentry')
.forEach((row) => pushPair(row));
}
return pairs;
}
function getLobbyAccountInfo() {
const accountSet = new Set();
const guestSet = new Set();
const levelMap = new Map();
const pairs = getLobbyPlayerIdentityPairs();
pairs.forEach(({ nameText, levelTextRaw }) => {
const nm = String(nameText || '').trim();
if (!nm) return;
const key = normalizeName(nm);
const levelText = String(levelTextRaw || '').trim().toLowerCase();
const isGuest = levelText === 'guest';
const isAccount = !!levelText && !isGuest;
if (isGuest) guestSet.add(key);
else if (isAccount) accountSet.add(key);
if (isGuest) levelMap.set(key, 'guest');
else if (isAccount) levelMap.set(key, 'level');
else if (!levelMap.has(key)) levelMap.set(key, 'unknown');
});
return { accountSet, guestSet, levelMap };
}
let cachedLobbyAccountInfo = null;
let cachedLobbyAccountInfoAt = 0;
function getLobbyAccountInfoCached(maxAgeMs = 800) {
const now = Date.now();
if (cachedLobbyAccountInfo && (now - cachedLobbyAccountInfoAt) <= Math.max(0, maxAgeMs)) {
return cachedLobbyAccountInfo;
}
cachedLobbyAccountInfo = getLobbyAccountInfo();
cachedLobbyAccountInfoAt = now;
return cachedLobbyAccountInfo;
}
function getSelfNameNorm() {
const el = $('pretty_top_name');
return el ? normalizeName(el.textContent || '') : '';
}
function getLobbyMessageText(msgContainer) {
const txt = msgContainer.querySelector('.newbonklobby_chat_msg_txt');
return (txt ? txt.textContent : msgContainer.textContent || '').trim();
}
function getInGameMessageText(row, nameSpan) {
const textSpan = row.querySelector('.ingamechatmessage, .ingamechattext');
if (textSpan) return (textSpan.textContent || '').trim();
const full = (row.textContent || '').trim();
const nameRaw = (nameSpan && nameSpan.textContent ? nameSpan.textContent : '').trim();
return nameRaw ? full.replace(nameRaw, '').trim() : full;
}
function getLobbyMessagePartsFromRow(msgEl) {
const nameSpan = msgEl.querySelector('.newbonklobby_chat_msg_name');
const txtSpan = msgEl.querySelector('.newbonklobby_chat_msg_txt');
let sender = nameSpan ? extractNameFromChatNameSpan(nameSpan) : '';
let msgText = txtSpan ? (txtSpan.textContent || '').trim() : '';
if (!sender || !msgText) {
const full = (msgEl.textContent || '').trim();
const idx = full.indexOf(':');
const idxWide = full.indexOf(':');
const splitAt = (idx >= 0 && idxWide >= 0) ? Math.min(idx, idxWide) : Math.max(idx, idxWide);
if (splitAt > 0) {
sender = sender || extractChatName(full.slice(0, splitAt));
msgText = msgText || full.slice(splitAt + 1).trim();
} else {
msgText = msgText || full;
}
}
return { sender, msgText, nameSpan };
}
function getInGameMessagePartsFromRow(row) {
const nameSpan = row.querySelector('.ingamechatname');
const sender = nameSpan ? extractNameFromChatNameSpan(nameSpan) : '';
const msgText = getInGameMessageText(row, nameSpan);
return { sender, msgText, nameSpan };
}
function isSystemStatusRow(row, isLobby, isInGame) {
if (!row || !(row instanceof Element)) return false;
if (hasSystemPrefix(row.textContent || '')) return true;
if (isLobby) {
if (row.querySelector('.newbonklobby_chat_status')) return true;
if (isLobbyMapRequestRow(row)) return true;
return false;
}
if (isInGame) {
if (row.dataset && row.dataset.tbcMirroredStatus === '1') return true;
if (row.querySelector('.newbonklobby_chat_status')) return true;
if (!row.querySelector('.ingamechatname') && !!getIngameStatusTextFromRow(row)) return true;
}
return false;
}
function hasDup(list, value, normalizeFn) {
const v = normalizeFn(value);
return list.some((x) => normalizeFn(x) === v);
}
function messageIsBlacklisted(senderName) {
const n = normalizeName(senderName);
if (chatState.blacklistUsers.some((u) => normalizeName(u) === n)) return true;
return false;
}
window.tbcIsNameBlacklisted = (name) => {
return messageIsBlacklisted(name);
};
window.tbcToggleBlacklistName = (name) => {
const raw = String(name || '').trim();
if (!raw) return false;
const n = normalizeName(raw);
if (!n) return false;
let changed = false;
mutateChatState((state) => {
const exists = state.blacklistUsers.some((x) => normalizeName(x) === n);
if (exists) {
const before = state.blacklistUsers.length;
state.blacklistUsers = state.blacklistUsers.filter((x) => normalizeName(x) !== n);
changed = state.blacklistUsers.length !== before;
return;
}
state.blacklistUsers.push(raw);
changed = true;
});
if (typeof refreshChatSettingsUi === 'function') refreshChatSettingsUi();
scheduleChatScan();
return changed;
};
function isPlayerSender(senderNorm, selfNorm, accountNameSet, guestNameSet, canVerify) {
if (!senderNorm) return false;
if (selfNorm && senderNorm === selfNorm) return true;
if (senderNorm === 'guest') return true;
if (accountNameSet.has(senderNorm) || guestNameSet.has(senderNorm)) return true;
if (!canVerify) {
if (
senderNorm === 'system' ||
senderNorm === 'server' ||
senderNorm === 'announcement' ||
senderNorm === 'announcer'
) {
return false;
}
return true;
}
return false;
}
function unwrapHiddenIfPresent(msgEl) {
if (!msgEl) return;
const wrap = msgEl.querySelector(':scope > .tbc_chat_original');
const placeholder = msgEl.querySelector(':scope > .tbc_chat_hidden_notice');
if (wrap) {
while (wrap.firstChild) msgEl.insertBefore(wrap.firstChild, wrap);
wrap.remove();
}
if (placeholder) placeholder.remove();
delete msgEl.dataset.tbcHiddenReady;
delete msgEl.dataset.tbcUserRevealed;
}
function setBlacklistedPresentation(msgEl, isBlacklisted) {
if (!msgEl) return;
unwrapHiddenIfPresent(msgEl);
if (isBlacklisted) msgEl.classList.add('tbc_chat_invisible');
else msgEl.classList.remove('tbc_chat_invisible');
}
let chatScanQueued = false;
let lastFullChatScanAt = 0;
let chatTouchedQueued = false;
const touchedChatRows = new Set();
const messageRowsByAuthor = new Map();
let slashCommandsInstalled = false;
let lastSlashSig = '';
let lastSlashAt = 0;
let lastSlashSuppress = false;
let lastUnknownSlashHelpAt = 0;
let topAdClickthroughObserver = null;
let lobbyStatusMirrorSeq = 0;
let lobbyUserPinnedUp = false;
let lobbyScrollContainerRef = null;
let wasIngameChatInteracting = false;
let stashedIngameSystemRows = [];
let stashedIngameSystemSeq = 0;
let replaySystemProtectionDisabled = false;
let replayRendererWasVisibleOnce = false;
const stashedReplayLobbyRows = new Map();
function clearTransientChatCarryoverState() {
touchedChatRows.clear();
messageRowsByAuthor.clear();
resetWinnerBoardTransientState();
if (stashedIngameSystemRows.length) {
stashedIngameSystemRows.forEach((entry) => {
if (!entry || typeof entry !== 'object') return;
const row = entry.row;
const placeholder = entry.placeholder;
if (placeholder instanceof Element && placeholder.parentElement) placeholder.remove();
if (row instanceof Element) {
delete row.dataset.tbcStashedSystem;
delete row.dataset.tbcStashedSystemSeq;
if (row.parentElement) row.remove();
}
});
stashedIngameSystemRows = [];
}
if (stashedReplayLobbyRows.size) {
for (const entry of stashedReplayLobbyRows.values()) {
if (!entry || typeof entry !== 'object') continue;
const row = entry.row;
const placeholder = entry.placeholder;
if (placeholder instanceof Element && placeholder.parentElement) placeholder.remove();
if (row instanceof Element && row.parentElement) row.remove();
}
stashedReplayLobbyRows.clear();
}
}
function scheduleChatScan() {
if (chatScanQueued) return;
chatScanQueued = true;
requestAnimationFrame(() => {
chatScanQueued = false;
scanAndApplyChatRules();
});
}
function forceTopAdOverlaysClickThrough() {
try {
const topWin = window.top;
if (!topWin || !topWin.document) return;
const doc = topWin.document;
const ids = [
'adboxverticalleftCurse',
'adboxverticalCurse',
'adboxverticalleft',
'adboxvertical',
];
ids.forEach((id) => {
const el = doc.getElementById(id);
if (!el || !(el instanceof topWin.Element)) return;
el.style.setProperty('pointer-events', 'none', 'important');
});
} catch {}
}
function ensureTopAdClickthroughWatcher() {
if (topAdClickthroughObserver) return;
forceTopAdOverlaysClickThrough();
try {
const topWin = window.top;
if (!topWin || !topWin.document) return;
const root = topWin.document.body || topWin.document.documentElement;
if (!root) return;
topAdClickthroughObserver = new MutationObserver(() => forceTopAdOverlaysClickThrough());
topAdClickthroughObserver.observe(root, {
childList: true,
subtree: true,
});
} catch {}
}
function getLobbyScrollContainer() {
const host = $('newbonklobby_chat_content');
if (!host) return null;
const isScrollable = (el) => !!el && (el.scrollHeight - el.clientHeight) > 2;
if (isScrollable(host)) return host;
let cur = host.parentElement;
while (cur && cur !== document.body) {
if (isScrollable(cur)) return cur;
cur = cur.parentElement;
}
return host;
}
function isContainerNearBottom(el, thresholdPx = 10) {
if (!el) return false;
return (el.scrollHeight - el.scrollTop - el.clientHeight) < thresholdPx;
}
function isContainerExactlyAtBottom(el) {
if (!el) return false;
return Math.abs((el.scrollHeight - el.clientHeight) - el.scrollTop) <= 1;
}
function updateLobbyPinnedStateFromContainer() {
const scrollEl = lobbyScrollContainerRef || getLobbyScrollContainer();
if (!scrollEl) return;
lobbyUserPinnedUp = !isContainerNearBottom(scrollEl, 24);
}
function ensureLobbyScrollContainerTracking() {
const scrollEl = getLobbyScrollContainer();
if (!scrollEl) return null;
if (lobbyScrollContainerRef === scrollEl) return scrollEl;
if (lobbyScrollContainerRef) {
lobbyScrollContainerRef.removeEventListener('scroll', updateLobbyPinnedStateFromContainer);
}
lobbyScrollContainerRef = scrollEl;
updateLobbyPinnedStateFromContainer();
lobbyScrollContainerRef.addEventListener('scroll', updateLobbyPinnedStateFromContainer, { passive: true });
return scrollEl;
}
function maybeScrollLobbyToBottom(scrollEl, wasNearBottom = false) {
if (!scrollEl) return;
if (wasNearBottom) {
scrollEl.scrollTop = scrollEl.scrollHeight;
return;
}
updateLobbyPinnedStateFromContainer();
if (lobbyUserPinnedUp) return;
if (isContainerNearBottom(scrollEl, 10)) {
scrollEl.scrollTop = scrollEl.scrollHeight;
}
}
function maybeScrollIngameToBottom(host, wasNearBottom = false) {
if (!host) return;
if (wasNearBottom || isContainerExactlyAtBottom(host)) {
host.scrollTop = host.scrollHeight;
}
}
function normalizeStatusText(text) {
let s = String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
s = s.replace(/[.!?]+$/g, '');
return s;
}
function isElementActuallyVisible(el) {
if (!el || !(el instanceof Element)) return false;
const cs = window.getComputedStyle(el);
if (!cs) return false;
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
if ((el.offsetWidth || 0) <= 0 && (el.offsetHeight || 0) <= 0) {
if ((el.getClientRects ? el.getClientRects().length : 0) <= 0) return false;
}
return true;
}
function updateReplaySystemProtectionLatch() {
if (replaySystemProtectionDisabled) return;
const renderer = $('gamerenderer');
if (!renderer) return;
const lobby = $('newbonklobby');
const rendererVisible = isElementActuallyVisible(renderer);
const lobbyVisible = isElementActuallyVisible(lobby);
if (rendererVisible) {
replayRendererWasVisibleOnce = true;
return;
}
if (replayRendererWasVisibleOnce && lobbyVisible) {
replaySystemProtectionDisabled = true;
if (!chatState.showSystemMessages) scheduleChatScan();
}
}
function isReplayRecorderSystemText(normalizedCore) {
return (
normalizedCore === 'replay must be at least 5 seconds long' ||
normalizedCore === 'the last 15 seconds have been recorded to the main menu' ||
normalizedCore === 'please wait at least 15 seconds since the last replay' ||
normalizedCore === 'no replays in football mode'
);
}
function forceReplaySystemRowColor(row) {
if (!row || !(row instanceof Element)) return;
const replayColor = chatState.useCustomSystemMessageColors
? (getSystemCategoryColorHex('replay') || REPLAY_SYSTEM_COLOR)
: REPLAY_SYSTEM_COLOR;
const carriers = row.querySelectorAll(
'.newbonklobby_chat_status, .ingamechatmessage, .ingamechattext, .ingamechatstatus'
);
if (carriers.length) {
carriers.forEach((el) => {
if (el.style.color !== replayColor) el.style.color = replayColor;
});
} else if (row.style.color !== replayColor) {
row.style.color = replayColor;
}
}
function stashIngameSystemRow(row) {
row = resolveIngameRow(row);
if (!row || !(row instanceof Element)) return false;
if (row.dataset.tbcStashedSystem === '1') return true;
stashedIngameSystemSeq += 1;
row.dataset.tbcStashedSystem = '1';
row.dataset.tbcStashedSystemSeq = String(stashedIngameSystemSeq);
const host = $('ingamechatcontent');
let placeholder = null;
if (host && row.parentElement === host) {
placeholder = document.createElement('div');
placeholder.style.display = 'none';
placeholder.dataset.tbcStashedSystemPlaceholder = '1';
placeholder.dataset.tbcStashedSystemSeq = String(stashedIngameSystemSeq);
host.insertBefore(placeholder, row);
}
stashedIngameSystemRows.push({
row,
placeholder,
seq: stashedIngameSystemSeq,
});
row.remove();
return true;
}
function restoreStashedIngameSystemRows() {
if (!chatState.showSystemMessages) return;
if (!stashedIngameSystemRows.length) return;
const host = $('ingamechatcontent');
if (!host) return;
const wasNearBottom = isContainerExactlyAtBottom(host);
const seen = new Set();
const rows = stashedIngameSystemRows
.filter((entry) => {
const row = entry && entry.row;
return row instanceof Element && !seen.has(row) && seen.add(row);
})
.sort((a, b) => (a.seq || 0) - (b.seq || 0));
stashedIngameSystemRows = [];
rows.forEach((entry) => {
const row = entry.row;
const placeholder = entry.placeholder;
delete row.dataset.tbcStashedSystem;
delete row.dataset.tbcStashedSystemSeq;
if (placeholder instanceof Element && placeholder.parentElement === host) {
host.insertBefore(row, placeholder);
placeholder.remove();
} else if (row.parentElement !== host) {
const fallbackSeenAt = getIngameRowSeenAt(row) || Date.now();
insertIngameRowBySeenAt(host, row, fallbackSeenAt);
}
addTouchedRow(row);
});
scheduleTouchedRowsFlush();
applyIngameChatLines();
applyIngameChatFade();
maybeScrollIngameToBottom(host, wasNearBottom);
}
function pruneHiddenIngameSystemRows() {
if (chatState.showSystemMessages) return;
const host = $('ingamechatcontent');
if (!host) return;
const rows = Array.from(host.children || []);
for (const raw of rows) {
const row = resolveIngameRow(raw) || raw;
if (!(row instanceof Element)) continue;
if (row.dataset.tbcStashedSystem === '1') continue;
const systemStatus = isSystemStatusRow(row, false, true) || isMapRequestStatusText(row.textContent || '');
if (!systemStatus) continue;
if (isProtectedIngameSystemStatus(row)) continue;
const text = row.textContent || '';
maybeDesyncOnHostClosedRoomStatus(text);
maybeDesyncOnHostTransferStatus(text);
row.dataset.tbcMsgType = 'system';
row.dataset.tbcMsgBlacklisted = '0';
row.dataset.tbcMsgGrouped = '0';
unregisterRowFromAuthorIndex(row);
unwrapHiddenIfPresent(row);
row.classList.remove('tbc_chat_invisible');
stashIngameSystemRow(row);
}
}
function getIngameStatusTextFromRow(row) {
if (!row || !(row instanceof Element)) return '';
if (row.querySelector('.ingamechatname')) return '';
const span = row.querySelector('.newbonklobby_chat_status, .ingamechatmessage, .ingamechattext, .ingamechatstatus');
const text = String(span ? span.textContent : row.textContent || '').trim();
if (!text) return '';
const first = text.charAt(0);
if (first !== '*' && first !== '/') return '';
return text;
}
function hasSystemPrefix(text) {
const s = String(text || '').trim();
if (!s) return false;
const first = s.charAt(0);
return first === '*' || first === '/';
}
function ingameHasStatusText(text, limit = 20) {
return !!findRecentIngameStatusRowByText(text, limit);
}
function findRecentIngameStatusRowByText(text, limit = 20) {
const host = $('ingamechatcontent');
if (!host) return null;
const target = normalizeStatusText(text);
if (!target) return null;
const rows = Array.from(host.children || []).slice(-Math.max(1, limit));
for (const row of rows) {
if (normalizeStatusText(getIngameStatusTextFromRow(row)) === target) return row;
}
return null;
}
function findRecentLobbyStatusColorByText(text, limit = 120) {
const target = normalizeStatusText(text);
if (!target) return '';
const statuses = Array.from(
document.querySelectorAll('#newbonklobby_chat_content .newbonklobby_chat_status')
).slice(-Math.max(1, limit));
for (let i = statuses.length - 1; i >= 0; i--) {
const st = statuses[i];
if (!(st instanceof Element)) continue;
if (normalizeStatusText(st.textContent || '') !== target) continue;
const c = String((st.style && st.style.color) || '').trim();
if (c) return c;
}
return '';
}
function resolveSystemStatusColor(statusEl, text = '') {
const configured = getSystemColorForText(text || (statusEl ? statusEl.textContent : ''));
if (configured && configured.color) return configured.color;
const inlineColor = String((statusEl && statusEl.style && statusEl.style.color) || '').trim();
if (inlineColor) return inlineColor;
let computedColor = '';
if (statusEl instanceof Element) {
computedColor = String(window.getComputedStyle(statusEl).color || '').trim();
}
if (computedColor && computedColor !== 'rgba(0, 0, 0, 0)') {
return computedColor;
}
const recentColor = findRecentLobbyStatusColorByText(text, 120);
if (recentColor) return recentColor;
return DEFAULT_SYSTEM_STATUS_COLOR;
}
function clearConfiguredSystemColorFromRow(row) {
if (!row || !(row instanceof Element)) return;
const carriers = row.querySelectorAll('.newbonklobby_chat_status, .ingamechatmessage, .ingamechattext, .ingamechatstatus');
if (carriers.length) {
carriers.forEach((el) => {
if (el.dataset && el.dataset.tbcSystemColorApplied === '1') {
el.style.removeProperty('color');
delete el.dataset.tbcSystemColorApplied;
}
});
} else if (row.dataset && row.dataset.tbcSystemColorApplied === '1') {
row.style.removeProperty('color');
delete row.dataset.tbcSystemColorApplied;
}
}
function applyConfiguredSystemColorToRow(row, text) {
if (!row || !(row instanceof Element)) return;
if (!chatState.useCustomSystemMessageColors) {
clearConfiguredSystemColorFromRow(row);
return;
}
const payload = getSystemColorForText(text || row.textContent || '');
const color = payload && payload.color ? payload.color : DEFAULT_SYSTEM_STATUS_COLOR;
const category = payload && payload.category ? payload.category : 'defaultSystem';
row.dataset.tbcSystemCategory = category;
const carriers = row.querySelectorAll('.newbonklobby_chat_status, .ingamechatmessage, .ingamechattext, .ingamechatstatus');
if (carriers.length) {
carriers.forEach((el) => {
if (el.style.color !== color) el.style.color = color;
if (el.dataset) el.dataset.tbcSystemColorApplied = '1';
});
} else if (row.style.color !== color) {
row.style.color = color;
row.dataset.tbcSystemColorApplied = '1';
}
}
function refreshVisibleSystemMessageRows() {
const selectors = ['#newbonklobby_chat_content > *', '#ingamechatcontent > *'];
selectors.forEach((sel) => {
const rows = Array.from(document.querySelectorAll(sel)).slice(-240);
rows.forEach((row) => {
if (!(row instanceof Element)) return;
const isInGame =
(row.classList && row.classList.contains('ingamechatentry')) ||
!!row.closest('#ingamechatcontent');
const isLobby =
!isInGame &&
(
(row.classList && row.classList.contains('newbonklobby_chat_msg')) ||
(row.parentElement && row.parentElement.id === 'newbonklobby_chat_content') ||
!!row.querySelector(':scope > .newbonklobby_chat_status')
);
if (!isLobby && !isInGame) return;
if (!isSystemStatusRow(row, isLobby, isInGame)) return;
if (chatState.useCustomSystemMessageColors) applyConfiguredSystemColorToRow(row, row.textContent || '');
else clearConfiguredSystemColorFromRow(row);
});
});
}
function syncNativeIngameSystemColorFromLobby(row) {
if (!row || !(row instanceof Element)) return;
if (row.dataset && row.dataset.tbcMirroredStatus === '1') return;
const text = getIngameStatusTextFromRow(row);
if (!text) return;
applyConfiguredSystemColorToRow(row, text);
}
function hasRecentNativeIngameStatusText(text, limit = 30, withinMs = 500) {
const host = $('ingamechatcontent');
if (!host) return false;
const target = normalizeStatusText(text);
if (!target) return false;
const rows = Array.from(host.children || []).slice(-Math.max(1, limit));
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (!(row instanceof Element)) continue;
if (row.dataset && row.dataset.tbcMirroredStatus === '1') continue;
const rowText = normalizeStatusText(getIngameStatusTextFromRow(row) || row.textContent || '');
if (rowText !== target) continue;
const t = parseInt(
String(row.dataset.tbcFadeBornAt || row.dataset.tbcMirroredAt || ''),
10
);
if (!Number.isFinite(t) || t <= 0) return true;
if ((Date.now() - t) <= Math.max(0, withinMs)) return true;
}
return false;
}
function isJoinLeaveStatusText(text) {
const s = String(text || '').toLowerCase();
return /\bhas joined the game\b/.test(s) || /\bhas left the game\b/.test(s);
}
function isMapRequestStatusText(text) {
const s = normalizeStatusText(String(text || '')).replace(/^\*\s*/, '');
if (!s) return false;
return /\brequests\b/.test(s) && /\bby\b/.test(s);
}
function isProtectedIngameSystemStatus(row) {
if (!row || !(row instanceof Element)) return false;
const s = String(row.textContent || '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
if (!s) return false;
const core = s.replace(/^\*\s*/, '');
const normalizedCore = normalizeStatusText(core);
if (/^\*\s+.+\s+has joined the game\.?$/.test(s)) return true;
if (/^\*\s+.+\s+has left the game\.?$/.test(s)) return true;
if (/^\*\s+.+\s+has left the game and .+ is now the game host\.?$/.test(s)) return true;
if (/^(?:\*\s*)?.+\s+has given host privileges to .+, who is now the game host\.?$/.test(s)) return true;
if (
/\byou are now the host of this game\b/.test(normalizedCore) ||
/\byou are now the game host\b/.test(normalizedCore)
) return true;
if (isReplayRecorderSystemText(normalizedCore)) return !replaySystemProtectionDisabled;
if (core.includes('room name changed to')) return true;
if (core.includes('a new password has been set for this room')) return true;
if (core.includes('this room no longer requires a password to join')) return true;
if (core.includes('room pass changed to') || core.includes('room password changed to')) return true;
if (core.includes('room pass cleared') || core.includes('room password cleared')) return true;
if (
core.includes('/roomname') &&
core.includes('must type') &&
core.includes('new desired room name')
) return true;
if (
core.includes('/roompass') &&
core.includes('must type') &&
core.includes('new desired password')
) return true;
if (
core.includes('must be room host to use this command') ||
core.includes('must be game host to use this command')
) return true;
if (
core.includes('you must be logged in') &&
(core.includes('map must be a bonk 2 map') || core.includes('map must be a bonk2 map'))
) return true;
if (
core.includes('you cannot favourite bonk 1 map') ||
core.includes('you cannot favorite bonk 1 map')
) return true;
if (core.includes('map added to favourite') || core.includes('map added to favorite')) return true;
if (core.includes('map removed from favourite') || core.includes('map removed from favorite')) return true;
return false;
}
function isLobbyMapRequestRow(row) {
if (!row || !(row instanceof Element)) return false;
const hasMapSuggestBits =
!!row.querySelector('.newbonklobby_mapsuggest_low') &&
!!row.querySelector('.newbonklobby_mapsuggest_high');
if (!hasMapSuggestBits) return false;
const nameSpan = row.querySelector('.newbonklobby_chat_msg_name');
const nameText = String(nameSpan ? nameSpan.textContent : '').trim();
if (!/^\*/.test(nameText)) return false;
return isMapRequestStatusText(row.textContent || '');
}
function hasRecentIngameSystemText(text, limit = 20, withinMs = 250) {
const row = findRecentIngameStatusRowByText(text, limit);
if (!row) return false;
const t = parseInt(
String(row.dataset.tbcMirroredAt || row.dataset.tbcFadeBornAt || ''),
10
);
if (!Number.isFinite(t) || t <= 0) return false;
return (Date.now() - t) <= Math.max(0, withinMs);
}
function isRepeatableMirroredSystemText(text) {
const s = normalizeStatusText(String(text || '')).replace(/^\*\s*/, '');
if (!s) return false;
if (/\bgame start(?:s|ing)? in [1-3]\b/.test(s)) return true;
if (s.includes("you're doing that too much") || s.includes('you are doing that too much')) return true;
return false;
}
function lobbyHasStatusText(text, limit = 20) {
const target = normalizeStatusText(text);
if (!target) return false;
const spans = Array.from(document.querySelectorAll('#newbonklobby_chat_content .newbonklobby_chat_status'))
.slice(-Math.max(1, limit));
return spans.some((sp) => normalizeStatusText(sp.textContent || '') === target);
}
function syncMirroredIngameStatusFromLobby(statusEl) {
if (!statusEl) return false;
const sourceStatusId = statusEl.dataset ? statusEl.dataset.tbcStatusId : '';
if (!sourceStatusId) return false;
const host = $('ingamechatcontent');
if (!host) return false;
const wasNearBottom = isContainerExactlyAtBottom(host);
const mirrored = host.querySelector(`.ingamechatentry[data-tbc-source-status-id="${sourceStatusId}"]`);
if (!mirrored) return false;
const sourceRow =
(statusEl.closest && statusEl.closest('#newbonklobby_chat_content > div')) ||
statusEl.parentElement;
if (!sourceRow) return false;
const oldBornAt = mirrored.dataset.tbcFadeBornAt || '';
const oldMirroredAt = mirrored.dataset.tbcMirroredAt || '';
const resolvedColor = resolveSystemStatusColor(statusEl, statusEl.textContent || '');
mirrored.innerHTML = sourceRow.innerHTML;
mirrored.classList.add('ingamechatentry');
mirrored.dataset.tbcMirroredStatus = '1';
mirrored.dataset.tbcSourceStatusId = sourceStatusId;
if (oldBornAt) mirrored.dataset.tbcFadeBornAt = oldBornAt;
if (oldMirroredAt) mirrored.dataset.tbcMirroredAt = oldMirroredAt;
const statusSpans = mirrored.querySelectorAll('.newbonklobby_chat_status');
statusSpans.forEach((sp) => {
sp.classList.add('ingamechatmessage');
if (!sp.style.display) sp.style.display = 'inline';
sp.style.color = resolvedColor;
});
addTouchedRow(mirrored);
scheduleTouchedRowsFlush();
applyIngameChatLines();
applyIngameChatFade();
maybeScrollIngameToBottom(host, wasNearBottom);
return true;
}
function getIngameRowSeenAt(row) {
if (!row || !(row instanceof Element)) return 0;
const t = parseInt(
String(
row.dataset.tbcFadeBornAt ||
row.dataset.tbcMirroredAt ||
((row.querySelector(':scope > .newbonklobby_chat_status') || {}).dataset || {}).tbcStatusSeenAt ||
''
),
10
);
return Number.isFinite(t) && t > 0 ? t : 0;
}
function insertIngameRowBySeenAt(host, row, seenAt) {
if (!host || !row || !(row instanceof Element)) return;
const eventAt = Number.isFinite(seenAt) && seenAt > 0 ? seenAt : Date.now();
const siblings = Array.from(host.children || []);
let insertBeforeNode = null;
for (const sib of siblings) {
if (!(sib instanceof Element)) continue;
if (sib === row) continue;
const sibAt = getIngameRowSeenAt(sib);
if (sibAt > 0 && sibAt > eventAt) {
insertBeforeNode = sib;
break;
}
}
if (insertBeforeNode) host.insertBefore(row, insertBeforeNode);
else host.appendChild(row);
}
function mirrorLobbyStatusToIngame(statusEl, opts = null) {
if (!statusEl) return;
if (statusEl.dataset.tbcMirroredFromIngame === '1') return;
const host = $('ingamechatcontent');
if (!host) return;
const wasNearBottom = isContainerExactlyAtBottom(host);
if (!statusEl.dataset.tbcStatusId) {
lobbyStatusMirrorSeq += 1;
statusEl.dataset.tbcStatusId = `st_${Date.now()}_${lobbyStatusMirrorSeq}`;
}
let sourceStatusId = statusEl.dataset.tbcStatusId;
const existingMirroredRow = sourceStatusId
? host.querySelector(`.ingamechatentry[data-tbc-source-status-id="${sourceStatusId}"]`)
: null;
if (existingMirroredRow) {
const curText = normalizeStatusText(statusEl.textContent || '');
const prevText = normalizeStatusText(
getIngameStatusTextFromRow(existingMirroredRow) || existingMirroredRow.textContent || ''
);
if (curText && prevText && curText !== prevText) {
lobbyStatusMirrorSeq += 1;
sourceStatusId = `st_${Date.now()}_${lobbyStatusMirrorSeq}`;
statusEl.dataset.tbcStatusId = sourceStatusId;
delete statusEl.dataset.tbcMirroredIngame;
} else {
statusEl.dataset.tbcMirroredIngame = '1';
syncMirroredIngameStatusFromLobby(statusEl);
return;
}
} else if (statusEl.dataset.tbcMirroredIngame === '1') {
delete statusEl.dataset.tbcMirroredIngame;
}
const requireRecentMs = opts && Number.isFinite(opts.requireRecentMs) ? opts.requireRecentMs : 0;
const seenAtRaw = statusEl.dataset.tbcStatusSeenAt || '';
const seenAt = parseInt(seenAtRaw, 10);
if (requireRecentMs > 0) {
if (!Number.isFinite(seenAt) || seenAt <= 0) return;
if ((Date.now() - seenAt) > requireRecentMs) return;
}
const text = String(statusEl.textContent || '').trim();
if (!text) return;
const resolvedColor = resolveSystemStatusColor(statusEl, text);
if (hasRecentNativeIngameStatusText(text, 40, 700)) {
statusEl.dataset.tbcMirroredIngame = '1';
return;
}
const sourceRow =
(statusEl.closest && statusEl.closest('#newbonklobby_chat_content > div')) ||
statusEl.parentElement;
const row = sourceRow ? sourceRow.cloneNode(true) : document.createElement('div');
if (!sourceRow) {
const msg = document.createElement('span');
msg.className = 'newbonklobby_chat_status';
msg.textContent = text;
msg.style.color = resolvedColor;
row.appendChild(msg);
}
row.classList.add('ingamechatentry');
row.dataset.tbcMirroredStatus = '1';
const eventSeenAt = Number.isFinite(seenAt) && seenAt > 0 ? seenAt : Date.now();
row.dataset.tbcMirroredAt = String(eventSeenAt);
if (sourceStatusId) row.dataset.tbcSourceStatusId = sourceStatusId;
const statusSpans = row.querySelectorAll('.newbonklobby_chat_status');
statusSpans.forEach((sp) => {
sp.classList.add('ingamechatmessage');
if (!sp.style.display) sp.style.display = 'inline';
sp.style.color = resolvedColor;
});
row.dataset.tbcFadeBornAt = String(eventSeenAt);
insertIngameRowBySeenAt(host, row, eventSeenAt);
statusEl.dataset.tbcMirroredIngame = '1';
addTouchedRow(row);
scheduleTouchedRowsFlush();
applyIngameChatLines();
applyIngameChatFade();
maybeScrollIngameToBottom(host, wasNearBottom);
}
function mirrorLobbyMapRequestRowToIngame(rowEl, opts = null) {
if (!rowEl || !(rowEl instanceof Element)) return;
if (!isLobbyMapRequestRow(rowEl)) return;
if (rowEl.dataset.tbcMirroredFromIngame === '1') return;
const host = $('ingamechatcontent');
if (!host) return;
const wasNearBottom = isContainerExactlyAtBottom(host);
if (!rowEl.dataset.tbcStatusId) {
lobbyStatusMirrorSeq += 1;
rowEl.dataset.tbcStatusId = `st_${Date.now()}_${lobbyStatusMirrorSeq}`;
}
let sourceStatusId = rowEl.dataset.tbcStatusId;
const existingMirroredRow = sourceStatusId
? host.querySelector(`.ingamechatentry[data-tbc-source-status-id="${sourceStatusId}"]`)
: null;
if (existingMirroredRow) {
const curText = normalizeStatusText(rowEl.textContent || '');
const prevText = normalizeStatusText(
getIngameStatusTextFromRow(existingMirroredRow) || existingMirroredRow.textContent || ''
);
if (curText && prevText && curText !== prevText) {
lobbyStatusMirrorSeq += 1;
sourceStatusId = `st_${Date.now()}_${lobbyStatusMirrorSeq}`;
rowEl.dataset.tbcStatusId = sourceStatusId;
delete rowEl.dataset.tbcMirroredIngame;
} else {
rowEl.dataset.tbcMirroredIngame = '1';
return;
}
} else if (rowEl.dataset.tbcMirroredIngame === '1') {
delete rowEl.dataset.tbcMirroredIngame;
}
const requireRecentMs = opts && Number.isFinite(opts.requireRecentMs) ? opts.requireRecentMs : 0;
const seenAtRaw = rowEl.dataset.tbcStatusSeenAt || '';
const seenAt = parseInt(seenAtRaw, 10);
if (requireRecentMs > 0) {
if (!Number.isFinite(seenAt) || seenAt <= 0) return;
if ((Date.now() - seenAt) > requireRecentMs) return;
}
const text = String(rowEl.textContent || '').trim();
if (!text) return;
if (hasRecentNativeIngameStatusText(text, 40, 700)) {
rowEl.dataset.tbcMirroredIngame = '1';
return;
}
const row = rowEl.cloneNode(true);
row.classList.add('ingamechatentry');
row.dataset.tbcMirroredStatus = '1';
const eventSeenAt = Number.isFinite(seenAt) && seenAt > 0 ? seenAt : Date.now();
row.dataset.tbcMirroredAt = String(eventSeenAt);
if (sourceStatusId) row.dataset.tbcSourceStatusId = sourceStatusId;
row.dataset.tbcFadeBornAt = String(eventSeenAt);
insertIngameRowBySeenAt(host, row, eventSeenAt);
rowEl.dataset.tbcMirroredIngame = '1';
addTouchedRow(row);
scheduleTouchedRowsFlush();
applyIngameChatLines();
applyIngameChatFade();
maybeScrollIngameToBottom(host, wasNearBottom);
}
function syncLobbyStatusesToIngame(limit = 120) {
const host = $('ingamechatcontent');
if (!host) return;
const bootstrapRecentMs = 2200;
const statuses = Array.from(
document.querySelectorAll('#newbonklobby_chat_content .newbonklobby_chat_status')
).slice(-Math.max(1, limit));
statuses.forEach((st) => {
if (!(st instanceof Element)) return;
if (!st.dataset.tbcStatusSeenAt) st.dataset.tbcStatusSeenAt = String(Date.now());
if (!st.dataset.tbcStatusId) {
lobbyStatusMirrorSeq += 1;
st.dataset.tbcStatusId = `st_${Date.now()}_${lobbyStatusMirrorSeq}`;
}
const statusTextNorm = normalizeStatusText(st.textContent || '').replace(/^\*\s*/, '');
const forceHostProtectedMirror =
/\bhas given host privileges to\b/.test(statusTextNorm) && /\bwho is now the game host\b/.test(statusTextNorm) ||
/\byou are now the host of this game\b/.test(statusTextNorm) ||
/\byou are now the game host\b/.test(statusTextNorm) ||
/\bhas left the game and .+ is now the game host\b/.test(statusTextNorm);
const forceReplayProtectedMirror = isReplayRecorderSystemText(statusTextNorm);
mirrorLobbyStatusToIngame(st, {
requireRecentMs: (forceHostProtectedMirror || forceReplayProtectedMirror) ? 0 : bootstrapRecentMs,
});
});
const mapReqRows = Array.from(
document.querySelectorAll('#newbonklobby_chat_content > *')
)
.filter((row) => row instanceof Element && isLobbyMapRequestRow(row))
.slice(-Math.max(1, limit));
mapReqRows.forEach((row) => {
if (!row.dataset.tbcStatusSeenAt) row.dataset.tbcStatusSeenAt = String(Date.now());
if (!row.dataset.tbcStatusId) {
lobbyStatusMirrorSeq += 1;
row.dataset.tbcStatusId = `st_${Date.now()}_${lobbyStatusMirrorSeq}`;
}
mirrorLobbyMapRequestRowToIngame(row, { requireRecentMs: bootstrapRecentMs });
});
}
function syncReplayStatusLobbyRows() {
const host = $('newbonklobby_chat_content');
if (!host) return;
const lobbyScrollEl = ensureLobbyScrollContainerTracking();
const lobbyWasNearBottom = lobbyScrollEl ? isContainerNearBottom(lobbyScrollEl, 10) : false;
function restoreStashedReplayLobbyRowsIfAny() {
if (!stashedReplayLobbyRows.size) return;
const entries = Array.from(stashedReplayLobbyRows.entries());
entries.forEach(([sourceId, stashed]) => {
if (!stashed || !(stashed.row instanceof Element)) {
stashedReplayLobbyRows.delete(sourceId);
return;
}
const ph = stashed.placeholder;
if (ph instanceof Element && ph.parentElement === host) {
host.insertBefore(stashed.row, ph);
ph.remove();
stashedReplayLobbyRows.delete(sourceId);
}
});
}
if (!chatState.showSystemMessages) {
host.querySelectorAll('[data-tbc-replay-lobby="1"]').forEach((row) => {
if (!(row instanceof Element)) return;
const sourceId = String(row.dataset.tbcReplaySourceId || '');
if (!sourceId) {
row.remove();
return;
}
if (!stashedReplayLobbyRows.has(sourceId)) {
const ph = document.createElement('div');
ph.dataset.tbcReplayLobbyPlaceholder = '1';
ph.dataset.tbcReplaySourceId = sourceId;
ph.style.display = 'none';
host.insertBefore(ph, row);
stashedReplayLobbyRows.set(sourceId, { row, placeholder: ph });
}
row.remove();
});
return;
}
restoreStashedReplayLobbyRowsIfAny();
const ingameHost = $('ingamechatcontent');
if (!ingameHost) return;
function getLobbyRowSeenAt(row) {
if (!row || !(row instanceof Element)) return 0;
const st = row.querySelector(':scope > .newbonklobby_chat_status');
if (!st) return 0;
const t = parseInt(
String((st.dataset && st.dataset.tbcStatusSeenAt) || row.dataset.tbcReplaySeenAt || ''),
10
);
return Number.isFinite(t) && t > 0 ? t : 0;
}
const rows = Array.from(ingameHost.children || []);
rows.forEach((rawRow) => {
const rowEl = resolveIngameRow(rawRow) || rawRow;
if (!(rowEl instanceof Element)) return;
const text = getIngameStatusTextFromRow(rowEl);
if (!text) return;
const replayCore = normalizeStatusText(text).replace(/^\*\s*/, '');
if (!isReplayRecorderSystemText(replayCore)) return;
if (!rowEl.dataset.tbcReplayLobbySourceId) {
lobbyStatusMirrorSeq += 1;
rowEl.dataset.tbcReplayLobbySourceId = `rpl_${Date.now()}_${lobbyStatusMirrorSeq}`;
}
const sourceId = rowEl.dataset.tbcReplayLobbySourceId;
if (!sourceId) return;
const eventSeenAt = (() => {
const t = parseInt(
String(
rowEl.dataset.tbcFadeBornAt ||
rowEl.dataset.tbcMirroredAt ||
rowEl.dataset.tbcStatusSeenAt ||
''
),
10
);
return Number.isFinite(t) && t > 0 ? t : Date.now();
})();
let wrapper = host.querySelector(`[data-tbc-replay-source-id="${sourceId}"]`);
const hadWrapper = wrapper instanceof Element;
if (!hadWrapper) {
const stashed = stashedReplayLobbyRows.get(sourceId);
if (stashed && stashed.row instanceof Element) wrapper = stashed.row;
else {
wrapper = document.createElement('div');
wrapper.dataset.tbcReplayLobby = '1';
wrapper.dataset.tbcReplaySourceId = sourceId;
}
}
wrapper.dataset.tbcReplayLobby = '1';
wrapper.dataset.tbcReplaySourceId = sourceId;
wrapper.dataset.tbcReplaySeenAt = String(eventSeenAt);
let span = wrapper.querySelector(':scope > .newbonklobby_chat_status');
if (!(span instanceof Element)) {
span = document.createElement('span');
span.className = 'newbonklobby_chat_status';
wrapper.textContent = '';
wrapper.appendChild(span);
}
const color = chatState.useCustomSystemMessageColors
? (getSystemCategoryColorHex('replay') || REPLAY_SYSTEM_COLOR)
: REPLAY_SYSTEM_COLOR;
span.textContent = text;
if (color) span.style.color = color;
else span.style.color = '';
span.dataset.tbcStatusSeenAt = String(eventSeenAt);
span.dataset.tbcMirroredFromIngame = '1';
span.dataset.tbcSourceIngameId = sourceId;
const children = Array.from(host.children || []);
let insertBeforeNode = null;
for (const child of children) {
if (!(child instanceof Element)) continue;
if (child === wrapper) continue;
const childSeenAt = getLobbyRowSeenAt(child);
if (childSeenAt > 0 && childSeenAt > eventSeenAt) {
insertBeforeNode = child;
break;
}
}
const stashed = stashedReplayLobbyRows.get(sourceId);
const placeholder =
stashed && stashed.placeholder instanceof Element ? stashed.placeholder : null;
if (placeholder && placeholder.parentElement === host) {
host.insertBefore(wrapper, placeholder);
placeholder.remove();
stashedReplayLobbyRows.delete(sourceId);
} else if (!hadWrapper || wrapper.parentElement !== host) {
if (insertBeforeNode) host.insertBefore(wrapper, insertBeforeNode);
else host.appendChild(wrapper);
}
});
maybeScrollLobbyToBottom(lobbyScrollEl, lobbyWasNearBottom);
}
function mirrorIngameStatusToLobby(rowEl) {
if (!rowEl || !(rowEl instanceof Element)) return;
if (rowEl.dataset.tbcMirroredStatus === '1') return;
const host = $('newbonklobby_chat_content');
if (!host) return;
const lobbyScrollEl = ensureLobbyScrollContainerTracking();
const lobbyWasNearBottom = lobbyScrollEl ? isContainerNearBottom(lobbyScrollEl, 10) : false;
const text = getIngameStatusTextFromRow(rowEl);
if (!text) return;
const replayCore = normalizeStatusText(text).replace(/^\*\s*/, '');
const replayStatus = isReplayRecorderSystemText(replayCore);
if (replayStatus) {
syncReplayStatusLobbyRows();
return;
}
if (lobbyHasStatusText(text, 20)) {
rowEl.dataset.tbcMirroredLobby = '1';
return;
}
if (!rowEl.dataset.tbcIngameStatusId) {
lobbyStatusMirrorSeq += 1;
rowEl.dataset.tbcIngameStatusId = `igst_${Date.now()}_${lobbyStatusMirrorSeq}`;
}
const srcId = rowEl.dataset.tbcIngameStatusId;
if (srcId && host.querySelector(`.newbonklobby_chat_status[data-tbc-source-ingame-id="${srcId}"]`)) {
rowEl.dataset.tbcMirroredLobby = '1';
return;
}
const sourceSpan = rowEl.querySelector('.newbonklobby_chat_status, .ingamechatmessage, .ingamechattext, .ingamechatstatus');
const color = sourceSpan && sourceSpan.style ? String(sourceSpan.style.color || '') : '';
const wrapper = document.createElement('div');
const span = document.createElement('span');
span.className = 'newbonklobby_chat_status';
span.textContent = text;
if (color) span.style.color = color;
span.dataset.tbcStatusSeenAt = String(Date.now());
span.dataset.tbcMirroredFromIngame = '1';
if (srcId) span.dataset.tbcSourceIngameId = srcId;
wrapper.appendChild(span);
host.appendChild(wrapper);
rowEl.dataset.tbcMirroredLobby = '1';
maybeScrollLobbyToBottom(lobbyScrollEl, lobbyWasNearBottom);
}
function addLocalChatStatus(text, color = '#317dd7') {
const host = document.getElementById('newbonklobby_chat_content');
if (!host) return null;
const lobbyScrollEl = ensureLobbyScrollContainerTracking();
const lobbyWasNearBottom = lobbyScrollEl ? isContainerNearBottom(lobbyScrollEl, 10) : false;
const row = document.createElement('div');
const span = document.createElement('span');
span.className = 'newbonklobby_chat_status';
span.style.color = color;
span.textContent = String(text || '');
span.dataset.tbcStatusSeenAt = String(Date.now());
row.appendChild(span);
host.appendChild(row);
maybeScrollLobbyToBottom(lobbyScrollEl, lobbyWasNearBottom);
return row;
}
function addSyncDisabledStatusNotice(options = null) {
const silent = !!(options && options.silent);
if (silent) return;
const row = addLocalChatStatus('[TBC] Sync is disabled during a running game. Sync in lobby.', 'rgb(181, 48, 48)');
const inGameVisible =
typeof isElementActuallyVisible === 'function' &&
!!isElementActuallyVisible($('gamerenderer'));
if (!inGameVisible || !(row instanceof Element)) return;
const statusEl = row.querySelector('.newbonklobby_chat_status');
if (!statusEl) return;
mirrorLobbyStatusToIngame(statusEl, { requireRecentMs: 15000 });
}
function addDesyncDisabledStatusNotice(options = null) {
const silent = !!(options && options.silent);
if (silent) return;
const row = addLocalChatStatus('[TBC] Desync is disabled during a running game. Desync in lobby.', 'rgb(181, 48, 48)');
const inGameVisible =
typeof isElementActuallyVisible === 'function' &&
!!isElementActuallyVisible($('gamerenderer'));
if (!inGameVisible || !(row instanceof Element)) return;
const statusEl = row.querySelector('.newbonklobby_chat_status');
if (!statusEl) return;
mirrorLobbyStatusToIngame(statusEl, { requireRecentMs: 15000 });
}
function appendSlashHelpStatusRows() {
const host = document.getElementById('newbonklobby_chat_content');
const lobbyScrollEl = ensureLobbyScrollContainerTracking();
const lobbyWasNearBottom = lobbyScrollEl ? isContainerNearBottom(lobbyScrollEl, 10) : false;
const lines = [
'/mapimg - download current map thumbnail',
'/copymap - copy current map thumbnail as PNG',
'/groups - toggle shared groups panel',
'/panels - toggle groups + points panels',
'/points - toggle points panel',
'/blacklist <player name> - blacklist an account name',
];
if (!host) {
lines.forEach((line) => addLocalChatStatus(line));
return;
}
const rows = lines.map((text) => {
const row = document.createElement('div');
row.dataset.tbcCustomSlash = '1';
const span = document.createElement('span');
span.className = 'newbonklobby_chat_status';
span.style.color = getSystemCategoryColorHex('customCommands');
span.textContent = text;
row.appendChild(span);
return row;
});
const children = Array.from(host.children);
let firstTrailingAsteriskRow = null;
for (let i = children.length - 1; i >= 0; i--) {
const status = children[i].querySelector(':scope > .newbonklobby_chat_status');
const txt = status ? String(status.textContent || '').trim() : '';
if (txt.startsWith('*')) {
firstTrailingAsteriskRow = children[i];
continue;
}
break;
}
rows.forEach((row) => host.insertBefore(row, firstTrailingAsteriskRow));
maybeScrollLobbyToBottom(lobbyScrollEl, lobbyWasNearBottom);
}
function getMapThumbDataUrl() {
const root = document.getElementById('newbonkgamecontainer') || document;
const candidates = [
root.querySelector('#isnewbonklobby_mapthumb_big'),
root.querySelector('#newbonklobby_mapthumb_big'),
root.querySelector('img#isnewbonklobby_mapthumb_big'),
root.querySelector('img#newbonklobby_mapthumb_big'),
root.querySelector('img[id*="mapthumb"][src^="data:image"]'),
root.querySelector('img[src^="data:image"]'),
].filter(Boolean);
for (const img of candidates) {
const src = String(img.getAttribute('src') || img.src || '').trim();
if (src && src.indexOf('data:image') === 0) return src;
}
return null;
}
function dataImageToPngBlob(dataUrl) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
try {
const w = img.naturalWidth || img.width || 0;
const h = img.naturalHeight || img.height || 0;
if (!w || !h) return reject(new Error('Invalid image size.'));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('Canvas context unavailable.'));
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (!blob) return reject(new Error('PNG conversion failed.'));
resolve(blob);
}, 'image/png');
} catch (e) {
reject(e);
}
};
img.onerror = () => reject(new Error('Could not decode map image.'));
img.src = dataUrl;
});
}
function triggerBlobDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
async function exportLobbyMapThumbAsPng() {
const dataUrl = getMapThumbDataUrl();
if (!dataUrl) {
addLocalChatStatus('[TBC] Map image not found.', 'rgb(181, 48, 48)');
return;
}
try {
const blob = await dataImageToPngBlob(dataUrl);
const ts = new Date();
const stamp =
ts.getFullYear().toString() +
String(ts.getMonth() + 1).padStart(2, '0') +
String(ts.getDate()).padStart(2, '0') + '_' +
String(ts.getHours()).padStart(2, '0') +
String(ts.getMinutes()).padStart(2, '0') +
String(ts.getSeconds()).padStart(2, '0');
triggerBlobDownload(blob, `bonk-map-${stamp}.png`);
addLocalChatStatus('[TBC] Map image exported as PNG.');
} catch (e) {
console.error('[TBC] map export failed', e);
addLocalChatStatus('[TBC] Map export failed.', 'rgb(181, 48, 48)');
}
}
async function copyLobbyMapThumbAsPng() {
const dataUrl = getMapThumbDataUrl();
if (!dataUrl) {
addLocalChatStatus('[TBC] Map image not found.', 'rgb(181, 48, 48)');
return;
}
try {
if (!navigator.clipboard || typeof navigator.clipboard.write !== 'function' || typeof ClipboardItem === 'undefined') {
addLocalChatStatus('[TBC] Clipboard image copy not supported in this browser.', 'rgb(181, 48, 48)');
return;
}
const blob = await dataImageToPngBlob(dataUrl);
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
addLocalChatStatus('[TBC] Map image copied to clipboard as PNG.');
} catch (e) {
console.error('[TBC] map copy failed', e);
addLocalChatStatus('[TBC] Clipboard copy failed (browser permission or support issue).', 'rgb(181, 48, 48)');
}
}
function maybeRunCustomSlashCommand(rawText) {
const text = String(rawText || '').trim();
if (!text || text.charAt(0) !== '/') return { suppress: false };
const now = Date.now();
const sig = text;
if (sig === lastSlashSig && (now - lastSlashAt) < 450) {
return { suppress: lastSlashSuppress };
}
lastSlashSig = sig;
lastSlashAt = now;
lastSlashSuppress = false;
const parts = text.split(/\s+/).filter(Boolean);
const cmd = (parts[0] || '').toLowerCase();
if (cmd === '/') return { suppress: false };
if (cmd === '/groups' || cmd === '/groupspanel') {
const arg = String(parts[1] || '').toLowerCase();
let nextVisible = groupsPanelVisible;
if (arg === 'on' || arg === 'show' || arg === '1' || arg === 'true') nextVisible = true;
else if (arg === 'off' || arg === 'hide' || arg === '0' || arg === 'false') nextVisible = false;
else nextVisible = !groupsPanelVisible;
setGroupsPanelVisible(nextVisible);
if (nextVisible) applySlashPanelsSpawnLayout('groups', true);
addLocalChatStatus(`[TBC] Shared groups panel ${groupsPanelVisible ? 'shown' : 'hidden'}.`);
lastSlashSuppress = true;
return { suppress: true };
}
if (cmd === '/panels') {
const arg = String(parts[1] || '').toLowerCase();
let showBoth = !(groupsPanelVisible && pointsPanelVisible);
if (arg === 'on' || arg === 'show' || arg === '1' || arg === 'true') showBoth = true;
else if (arg === 'off' || arg === 'hide' || arg === '0' || arg === 'false') showBoth = false;
setGroupsPanelVisible(showBoth);
setPointsPanelVisible(showBoth);
if (showBoth) applySlashPanelsSpawnLayout('groups', true);
addLocalChatStatus(`[TBC] Panels ${showBoth ? 'shown' : 'hidden'}.`);
lastSlashSuppress = true;
return { suppress: true };
}
if (cmd === '/points') {
const arg = String(parts[1] || '').toLowerCase();
let nextVisible = pointsPanelVisible;
if (arg === 'on' || arg === 'show' || arg === '1' || arg === 'true') nextVisible = true;
else if (arg === 'off' || arg === 'hide' || arg === '0' || arg === 'false') nextVisible = false;
else nextVisible = !pointsPanelVisible;
setPointsPanelVisible(nextVisible);
if (nextVisible) {
const primary = groupsPanelVisible ? 'groups' : 'points';
applySlashPanelsSpawnLayout(primary, true);
}
addLocalChatStatus(`[TBC] Points panel ${pointsPanelVisible ? 'shown' : 'hidden'}.`);
lastSlashSuppress = true;
return { suppress: true };
}
if (cmd === '/groupssync') {
if (!isSelfLobbyHost()) {
addLocalChatStatus('[TBC] Only the room host can trigger sync.', 'rgb(181, 48, 48)');
} else if (!canRunGroupsSyncNow()) {
addSyncDisabledStatusNotice();
} else {
const ok = broadcastSharedGroupsFromHost(true);
if (ok) {
markHostGroupsPanelSyncedForCurrentRoom();
if (groupsSyncTask) addLocalChatStatus('[TBC] Groups panel sync started.');
else addLocalChatStatus('[TBC] Groups panel refresh sent.');
}
}
lastSlashSuppress = true;
return { suppress: true };
}
if (cmd === '/groupsrelay') return { suppress: true };
if (cmd === '/mapimg') {
exportLobbyMapThumbAsPng();
lastSlashSuppress = true;
return { suppress: true };
}
if (cmd === '/copymap') {
copyLobbyMapThumbAsPng();
lastSlashSuppress = true;
return { suppress: true };
}
if (cmd === '/blacklist') {
const firstSpace = text.indexOf(' ');
const nameRaw = firstSpace === -1 ? '' : text.slice(firstSpace + 1);
const name = String(nameRaw || '').trim();
if (!name) {
addLocalChatStatus('[TBC] Usage: /blacklist <player name>', 'rgb(181, 48, 48)');
lastSlashSuppress = true;
return { suppress: true };
}
const n = normalizeName(name);
const selfNorm = getSelfNameNorm();
if (!n || (selfNorm && n === selfNorm)) {
addLocalChatStatus('[TBC] Cannot blacklist yourself.', 'rgb(181, 48, 48)');
lastSlashSuppress = true;
return { suppress: true };
}
const info = getLobbyAccountInfoCached(800);
const accountSet = info && info.accountSet ? info.accountSet : new Set();
const guestSet = info && info.guestSet ? info.guestSet : new Set();
const levelMap = info && info.levelMap ? info.levelMap : new Map();
const levelState = String(levelMap.get(n) || '');
if (n === 'guest' || guestSet.has(n) || levelState === 'guest') {
addLocalChatStatus('[TBC] /blacklist only accepts account names, not guests.', 'rgb(181, 48, 48)');
lastSlashSuppress = true;
return { suppress: true };
}
if (accountSet.size > 0 && !accountSet.has(n)) {
addLocalChatStatus('[TBC] Account not found in lobby player list.', 'rgb(181, 48, 48)');
lastSlashSuppress = true;
return { suppress: true };
}
let changed = false;
let removed = false;
mutateChatState((state) => {
const exists = state.blacklistUsers.some((x) => normalizeName(x) === n);
if (exists) {
const before = state.blacklistUsers.length;
state.blacklistUsers = state.blacklistUsers.filter((x) => normalizeName(x) !== n);
removed = state.blacklistUsers.length !== before;
changed = removed;
return;
}
state.blacklistUsers.push(name);
changed = true;
});
if (removed) addLocalChatStatus(`[TBC] Removed from blacklist: ${name}`);
else if (changed) addLocalChatStatus(`[TBC] Blacklisted account: ${name}`);
else addLocalChatStatus(`[TBC] No blacklist changes for: ${name}`);
if (typeof refreshChatSettingsUi === 'function') refreshChatSettingsUi();
scheduleChatScan();
lastSlashSuppress = true;
return { suppress: true };
}
return { suppress: false };
}
function closeLobbyChatComposer(inputEl) {
if (!(inputEl instanceof HTMLInputElement) && !(inputEl instanceof HTMLTextAreaElement)) return;
inputEl.value = '';
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
setTimeout(() => {
try {
const keydownEvt = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
});
const keyupEvt = new KeyboardEvent('keyup', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
});
inputEl.focus();
inputEl.dispatchEvent(keydownEvt);
inputEl.dispatchEvent(keyupEvt);
if (typeof inputEl.blur === 'function') inputEl.blur();
} catch {
if (typeof inputEl.blur === 'function') inputEl.blur();
}
}, 0);
}
function setupCustomSlashCommands() {
if (slashCommandsInstalled) return;
slashCommandsInstalled = true;
waitForElement('newbonklobby_chatbox', (chatBox) => {
chatBox.addEventListener(
'keydown',
(e) => {
if (e.key !== 'Enter') return;
const target = e.target;
if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) return;
const result = maybeRunCustomSlashCommand(target.value);
if (result && result.suppress) {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
closeLobbyChatComposer(target);
}
},
true
);
chatBox.addEventListener(
'click',
(e) => {
const target = e.target && e.target.closest ? e.target.closest('button, .brownButton') : null;
if (!target) return;
const input = chatBox.querySelector('input[type="text"], textarea');
if (!input) return;
const result = maybeRunCustomSlashCommand(input.value);
if (result && result.suppress) {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
closeLobbyChatComposer(input);
}
},
true
);
});
waitForElement('ingamechatinputtext', (input) => {
input.addEventListener(
'keydown',
(e) => {
if (e.key !== 'Enter') return;
const target = e.target;
if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) return;
const result = maybeRunCustomSlashCommand(target.value);
if (result && result.suppress) {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
target.value = '';
target.dispatchEvent(new Event('input', { bubbles: true }));
setTimeout(() => closeIngameChatInputByEnterTap(1), 35);
}
},
true
);
});
}
function setupIngameChatFocusGuards() {
if (ingameChatFocusGuardsInstalled) return;
ingameChatFocusGuardsInstalled = true;
let pendingRefocusUntil = 0;
let hoveringIngameChat = false;
let mousedownIngameChat = false;
let ingameChatHostRef = null;
const forceImmediateIngameChatSync = () => {
const host = ingameChatHostRef || $('ingamechatcontent');
if (!host) return;
const focused = isIngameChatInteracting();
updateIngameChatScrollFocusState(host, focused);
applyIngameChatLines();
applyIngameChatFade();
if (focused) host.scrollTop = host.scrollHeight;
};
const ensureInputFocusFor = (input, ms) => {
if (!(input instanceof HTMLInputElement) && !(input instanceof HTMLTextAreaElement)) return;
const until = Date.now() + Math.max(0, ms || 0);
pendingRefocusUntil = Math.max(pendingRefocusUntil, until);
const tick = (triesLeft) => {
if (Date.now() > pendingRefocusUntil) return;
if (document.activeElement !== input) input.focus();
if (triesLeft <= 0) return;
setTimeout(() => tick(triesLeft - 1), 50);
};
tick(8);
};
waitForElement('ingamechatcontent', (host) => {
ingameChatHostRef = host;
const updateHover = (clientX, clientY) => {
const rect = host.getBoundingClientRect();
hoveringIngameChat =
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom;
};
host.addEventListener('mouseenter', () => { hoveringIngameChat = true; }, true);
host.addEventListener('mouseleave', () => { hoveringIngameChat = false; }, true);
host.addEventListener('mousemove', (e) => updateHover(e.clientX, e.clientY), true);
host.addEventListener(
'mousedown',
(e) => {
const input = $('ingamechatinputtext');
if (!(input instanceof HTMLInputElement) && !(input instanceof HTMLTextAreaElement)) return;
if (document.activeElement !== input) return;
if (e.button !== 0) return;
mousedownIngameChat = true;
ensureInputFocusFor(input, 1200);
setTimeout(() => {
if (Date.now() <= pendingRefocusUntil && document.activeElement !== input) input.focus();
forceImmediateIngameChatSync();
}, 0);
},
true
);
document.addEventListener(
'mouseup',
() => {
mousedownIngameChat = false;
pendingRefocusUntil = Date.now() + 100;
},
true
);
document.addEventListener(
'wheel',
(e) => {
const rect = host.getBoundingClientRect();
const pointerInsideHost =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
const inputFocused = isIngameChatInteracting();
if (!pointerInsideHost && !hoveringIngameChat && !inputFocused) return;
const input = $('ingamechatinputtext');
const hasInput = (input instanceof HTMLInputElement) || (input instanceof HTMLTextAreaElement);
const canScroll = host.scrollHeight > host.clientHeight + 1;
if (!canScroll) {
e.preventDefault();
e.stopPropagation();
if (hasInput && document.activeElement !== input) input.focus();
return;
}
const dir = e.deltaY > 0 ? 1 : (e.deltaY < 0 ? -1 : 0);
if (dir !== 0) {
const cs = window.getComputedStyle(host);
const cssLine = parseFloat((cs && cs.getPropertyValue('--tbc-ingame-line-height')) || '');
const attrLine = parseFloat(host.getAttribute('data-tbc-runtime-per-line-height-px') || '');
const linePx = Math.max(1, Math.round((Number.isFinite(attrLine) && attrLine > 0) ? attrLine : ((Number.isFinite(cssLine) && cssLine > 0) ? cssLine : 19)));
host.scrollTop += dir * linePx;
}
e.preventDefault();
e.stopPropagation();
if (hasInput && document.activeElement !== input) input.focus();
},
{ passive: false, capture: true }
);
});
waitForElement('ingamechatinputtext', (input) => {
input.addEventListener(
'focus',
() => {
forceImmediateIngameChatSync();
requestAnimationFrame(() => forceImmediateIngameChatSync());
},
true
);
input.addEventListener(
'click',
() => {
forceImmediateIngameChatSync();
},
true
);
input.addEventListener(
'blur',
() => {
if (!mousedownIngameChat && Date.now() > pendingRefocusUntil) {
setTimeout(() => forceImmediateIngameChatSync(), 0);
return;
}
setTimeout(() => {
if (mousedownIngameChat || Date.now() <= pendingRefocusUntil) ensureInputFocusFor(input, 300);
}, 0);
},
true
);
});
document.addEventListener(
'keydown',
(e) => {
if (e.key !== 'Enter') return;
setTimeout(() => {
if (!isIngameChatInteracting()) return;
forceImmediateIngameChatSync();
}, 0);
},
true
);
}
function resolveChatRow(node) {
if (!node || !(node instanceof Element)) return null;
if (node.classList && node.classList.contains('ingamechatentry')) return node;
if (node.classList && node.classList.contains('newbonklobby_chat_msg')) return node;
const inGameTree = node.closest('#ingamechatcontent');
if (inGameTree) {
let cur = node;
while (cur && cur.parentElement !== inGameTree) cur = cur.parentElement;
if (cur && cur.parentElement === inGameTree) return cur;
}
const inLobbyTree = node.closest('#newbonklobby_chat_content');
if (inLobbyTree) {
let cur = node;
while (cur && cur.parentElement !== inLobbyTree) cur = cur.parentElement;
if (cur && cur.parentElement === inLobbyTree) return cur;
}
return null;
}
function getAuthorSet(authorNorm, create = false) {
if (!authorNorm) return null;
let set = messageRowsByAuthor.get(authorNorm);
if (!set && create) {
set = new Set();
messageRowsByAuthor.set(authorNorm, set);
}
return set || null;
}
function unregisterRowFromAuthorIndex(row) {
if (!row || !(row instanceof Element)) return;
const prevAuthor = String(row.dataset.tbcMsgAuthorNorm || '').trim();
if (!prevAuthor) return;
const set = getAuthorSet(prevAuthor, false);
if (!set) return;
set.delete(row);
if (!set.size) messageRowsByAuthor.delete(prevAuthor);
}
function registerRowInAuthorIndex(row, authorNorm) {
if (!row || !(row instanceof Element)) return;
const nextAuthor = String(authorNorm || '').trim();
const prevAuthor = String(row.dataset.tbcMsgAuthorNorm || '').trim();
if (prevAuthor && prevAuthor !== nextAuthor) unregisterRowFromAuthorIndex(row);
if (!nextAuthor) {
delete row.dataset.tbcMsgAuthorNorm;
return;
}
const set = getAuthorSet(nextAuthor, true);
set.add(row);
row.dataset.tbcMsgAuthorNorm = nextAuthor;
}
function pruneAuthorSet(authorNorm) {
const set = getAuthorSet(authorNorm, false);
if (!set) return [];
const out = [];
for (const row of set) {
if (!(row instanceof Element) || !row.isConnected) {
set.delete(row);
continue;
}
out.push(row);
}
if (!set.size) messageRowsByAuthor.delete(authorNorm);
return out;
}
function isGroupedColorEnabledForRow(row) {
const inGame = !!(row && row.closest && row.closest('#ingamechatcontent'));
return inGame ? !!recolorDisplaySettings.ingameChatNames : !!recolorDisplaySettings.lobbyChatNames;
}
function getNameSpanForRow(row) {
if (!row || !(row instanceof Element)) return null;
if (row.closest && row.closest('#ingamechatcontent')) {
return row.querySelector('.ingamechatname');
}
return row.querySelector('.newbonklobby_chat_msg_name');
}
function applyTaggedNameColorForRow(row) {
if (!row || !(row instanceof Element)) return;
if (String(row.dataset.tbcMsgType || '') !== 'user') return;
const nameSpan = getNameSpanForRow(row);
if (!nameSpan) return;
const raw = String(nameSpan.textContent || '').trim();
const author = extractChatName(raw);
const enabled = isGroupedColorEnabledForRow(row);
const color = enabled && hasDisplayGroupsForNames() ? getDisplayColorForName(author) : null;
setElementNameColor(nameSpan, color);
row.dataset.tbcMsgGrouped = color ? '1' : '0';
}
function ensureAuthorRowsIndexed(authorNorm) {
const key = normalizeName(authorNorm);
if (!key) return;
if (getAuthorSet(key, false) && getAuthorSet(key, false).size) return;
const spans = Array.from(document.querySelectorAll(
'#newbonklobby_chat_content .newbonklobby_chat_msg_name, #ingamechatcontent .ingamechatname'
));
spans.forEach((nameSpan) => {
const raw = String(nameSpan.textContent || '').trim();
const nm = normalizeName(extractChatName(raw));
if (!nm || nm !== key) return;
const row = resolveChatRow(nameSpan);
if (!row) return;
row.dataset.tbcMsgType = 'user';
registerRowInAuthorIndex(row, nm);
});
}
function refreshRowsForAuthors(authorNames) {
const list = Array.isArray(authorNames) ? authorNames : [authorNames];
const keys = Array.from(new Set(list.map((x) => normalizeName(x)).filter(Boolean)));
keys.forEach((key) => {
ensureAuthorRowsIndexed(key);
const rows = pruneAuthorSet(key);
rows.forEach((row) => {
row.dataset.tbcMsgBlacklisted = messageIsBlacklisted(key) ? '1' : '0';
applyTaggedNameColorForRow(row);
});
});
}
function refreshAllIndexedRows() {
const all = Array.from(messageRowsByAuthor.keys());
if (!all.length) return;
refreshRowsForAuthors(all);
}
function addTouchedRow(node) {
if (!node || !(node instanceof Element) || !node.isConnected) return;
const row = resolveChatRow(node);
if (!row || !row.isConnected) return;
touchedChatRows.add(row);
markIngameRowFadeBirth(row);
}
function scheduleTouchedRowsFlush() {
if (chatTouchedQueued) return;
chatTouchedQueued = true;
requestAnimationFrame(() => {
chatTouchedQueued = false;
if (!touchedChatRows.size) return;
const selfNorm = getSelfNameNorm();
const info = getLobbyAccountInfoCached(800);
const accountNameSet = info.accountSet;
const guestNameSet = info.guestSet;
const lobbyLevelMap = info.levelMap;
const canVerify = accountNameSet.size > 0 || guestNameSet.size > 0;
const now = Date.now();
if (canVerify && (now - lastTempGuestPruneAt) >= 900) {
lastTempGuestPruneAt = now;
purgeTemporaryGuestGroupMembers({ onlyMissingInLobby: true, lobbyInfo: info });
}
const rows = Array.from(touchedChatRows);
touchedChatRows.clear();
rows.forEach((row) => applyChatRulesToRow(row, selfNorm, accountNameSet, guestNameSet, canVerify, lobbyLevelMap));
applyIngameChatFade();
});
}
function applyChatRulesToRow(row, selfNorm, accountNameSet, guestNameSet, canVerify, lobbyLevelMap) {
row = resolveChatRow(row);
if (!row || !row.isConnected) return;
const isInGame =
(row.classList && row.classList.contains('ingamechatentry')) ||
!!row.closest('#ingamechatcontent');
const isLobby =
!isInGame &&
(
(row.classList && row.classList.contains('newbonklobby_chat_msg')) ||
(row.parentElement && row.parentElement.id === 'newbonklobby_chat_content') ||
!!row.querySelector(':scope > .newbonklobby_chat_msg_name, :scope > .newbonklobby_chat_msg_txt')
);
if (!isLobby && !isInGame) return;
const forcedSystemByText = isInGame && isMapRequestStatusText(row.textContent || '');
const systemStatus = isSystemStatusRow(row, isLobby, isInGame) || forcedSystemByText;
if (isInGame && systemStatus && !chatState.showSystemMessages && !isProtectedIngameSystemStatus(row)) {
maybeDesyncOnHostClosedRoomStatus(row.textContent || '');
maybeDesyncOnHostTransferStatus(row.textContent || '');
row.dataset.tbcMsgType = 'system';
row.dataset.tbcMsgBlacklisted = '0';
row.dataset.tbcMsgGrouped = '0';
unregisterRowFromAuthorIndex(row);
unwrapHiddenIfPresent(row);
row.classList.remove('tbc_chat_invisible');
stashIngameSystemRow(row);
return;
}
if (systemStatus) {
maybeDesyncOnHostClosedRoomStatus(row.textContent || '');
maybeDesyncOnHostTransferStatus(row.textContent || '');
row.dataset.tbcMsgType = 'system';
row.dataset.tbcMsgBlacklisted = '0';
row.dataset.tbcMsgGrouped = '0';
unregisterRowFromAuthorIndex(row);
unwrapHiddenIfPresent(row);
row.classList.remove('tbc_chat_invisible');
if (isInGame) {
syncNativeIngameSystemColorFromLobby(row);
const replayCore = normalizeStatusText(getIngameStatusTextFromRow(row) || row.textContent || '').replace(/^\*\s*/, '');
if (isReplayRecorderSystemText(replayCore)) forceReplaySystemRowColor(row);
}
applyConfiguredSystemColorToRow(row, row.textContent || '');
return;
}
const parts = isLobby ? getLobbyMessagePartsFromRow(row) : getInGameMessagePartsFromRow(row);
if (isLobby || isInGame) {
const syncTextRaw = String(parts.msgText || '').trim();
const syncText = parseGroupsSyncTransportMessage(syncTextRaw);
if (syncText) {
const syncSig = `${normalizeName(parts.sender)}|${syncText}`;
if (row.dataset && row.dataset.tbcGroupsSyncHandledSig === syncSig) {
row.classList.add('tbc_chat_invisible');
return;
}
if (handleIncomingGroupsSyncChunk(parts.sender, syncText)) {
if (row.dataset) row.dataset.tbcGroupsSyncHandledSig = syncSig;
row.classList.add('tbc_chat_invisible');
return;
}
}
}
const senderNorm = normalizeName(parts.sender);
row.dataset.tbcMsgType = 'user';
registerRowInAuthorIndex(row, senderNorm);
const isSelf = !!selfNorm && senderNorm === selfNorm;
const senderLobbyState = (lobbyLevelMap && senderNorm) ? String(lobbyLevelMap.get(senderNorm) || '') : '';
const isGuestLike =
senderNorm === 'guest' ||
guestNameSet.has(senderNorm) ||
senderLobbyState === 'guest';
const rendererVisible = isElementActuallyVisible($('gamerenderer'));
const hasLobbyElement = !!$('newbonklobby');
const senderIdentityUnknown =
!!senderNorm &&
senderLobbyState !== 'guest' &&
senderLobbyState !== 'level' &&
!guestNameSet.has(senderNorm) &&
!accountNameSet.has(senderNorm);
const fallbackGuestHideActive =
!!chatState.hideGuests &&
rendererVisible &&
hasLobbyElement &&
senderIdentityUnknown;
const fallbackTreatAsGuest =
fallbackGuestHideActive &&
!isSelf &&
!!senderNorm &&
senderNorm !== 'system' &&
senderNorm !== 'server' &&
senderNorm !== 'announcement' &&
senderNorm !== 'announcer';
if (!isSelf && chatState.hideGuests && (isGuestLike || fallbackTreatAsGuest)) {
unwrapHiddenIfPresent(row);
row.classList.add('tbc_chat_invisible');
row.dataset.tbcMsgGuestFallback = fallbackTreatAsGuest ? '1' : '0';
return;
}
row.dataset.tbcMsgGuestFallback = '0';
if (!(chatState.hideGuests && !isSelf && isGuestLike)) row.classList.remove('tbc_chat_invisible');
const canBlacklistSender = isPlayerSender(senderNorm, selfNorm, accountNameSet, guestNameSet, canVerify);
const blacklisted = !isSelf && canBlacklistSender && messageIsBlacklisted(parts.sender);
row.dataset.tbcMsgBlacklisted = blacklisted ? '1' : '0';
setBlacklistedPresentation(row, blacklisted);
if (parts.nameSpan) {
const colorEnabled = isInGame ? !!recolorDisplaySettings.ingameChatNames : !!recolorDisplaySettings.lobbyChatNames;
const nameColor = colorEnabled ? getDisplayColorForName(parts.sender) : null;
setElementNameColorWithGrace(parts.nameSpan, nameColor, colorEnabled, 'tbcChatNameNoColorSince', 1400);
row.dataset.tbcMsgGrouped = nameColor ? '1' : '0';
} else {
row.dataset.tbcMsgGrouped = '0';
}
}
function scanAndApplyChatRules() {
const now = Date.now();
if ((now - lastFullChatScanAt) < 120) return;
lastFullChatScanAt = now;
updateReplaySystemProtectionLatch();
const lobbyVisibleNow = isElementActuallyVisible($('newbonklobby'));
const rendererVisibleNow = isElementActuallyVisible($('gamerenderer'));
const syncEligibleNow = canRunGroupsSyncNow();
if (syncEligibleNow !== lastGroupsSyncEligibility) {
lastGroupsSyncEligibility = syncEligibleNow;
if (groupsPanelVisible) renderGroupsPanel();
}
flushPendingSharedGroupsDesyncIfReady();
if (
lastRendererVisibleForNonHostExitDesync === true &&
!rendererVisibleNow &&
lobbyVisibleNow &&
!isSelfLobbyHost() &&
(roomGroupsSyncActive || sharedHostGroupsSnapshot.length > 0)
) {
applySharedGroupsDesyncNow('[TBC] You left the game. Shared groups desynced.');
}
lastRendererVisibleForNonHostExitDesync = rendererVisibleNow;
if (rendererVisibleNow && lastRendererVisibleForPointsSnapshotReset === false) {
resetPointsPanelScoreSnapshot();
if (pointsPanelVisible) renderPointsPanel();
}
lastRendererVisibleForPointsSnapshotReset = rendererVisibleNow;
if (!document.hidden && !lobbyVisibleNow && !rendererVisibleNow) {
if (!roomUiBothHiddenSince) roomUiBothHiddenSince = now;
if ((now - roomUiBothHiddenSince) >= 1500) {
clearTransientChatCarryoverState();
if (roomGroupsSyncActive || sharedHostGroupsSnapshot.length > 0) {
sharedHostGroupsSnapshot = [];
groupsSyncChunksBySession = new Map();
liveGroupsSyncHostNorm = '';
setRoomGroupsSyncActive(false);
syncSharedGroupsBridge();
if (groupsPanelVisible) renderGroupsPanel();
}
purgeTemporaryGuestGroupMembers({ onlyMissingInLobby: false });
}
} else {
roomUiBothHiddenSince = 0;
}
maybeLoadRoomGroupsCache();
const lobbyScrollEl = ensureLobbyScrollContainerTracking();
const lobbyBox = document.getElementById('newbonklobby_chat_content');
const ingameBox = document.getElementById('ingamechatcontent');
const lobbyWasNearBottom = lobbyScrollEl
? isContainerNearBottom(lobbyScrollEl, 10)
: false;
const ingameWasNearBottom = ingameBox
? (ingameBox.scrollHeight - ingameBox.scrollTop - ingameBox.clientHeight) < 10
: false;
const selfNorm = getSelfNameNorm();
const info = getLobbyAccountInfoCached(800);
const accountNameSet = info.accountSet;
const guestNameSet = info.guestSet;
const lobbyLevelMap = info.levelMap;
const canVerify = accountNameSet.size > 0 || guestNameSet.size > 0;
const lobbyRows = Array.from((lobbyBox && lobbyBox.children) ? lobbyBox.children : [])
.map((el) => resolveChatRow(el))
.filter(Boolean)
.slice(-140);
lobbyRows.forEach((row) => {
applyChatRulesToRow(row, selfNorm, accountNameSet, guestNameSet, canVerify, lobbyLevelMap);
});
const inGameRows = Array.from((ingameBox && ingameBox.children) ? ingameBox.children : [])
.map((el) => resolveChatRow(el))
.filter(Boolean)
.slice(-140);
inGameRows.forEach((row) => {
applyChatRulesToRow(row, selfNorm, accountNameSet, guestNameSet, canVerify, lobbyLevelMap);
});
maybeScrollLobbyToBottom(lobbyScrollEl, lobbyWasNearBottom);
if (ingameBox && ingameWasNearBottom) ingameBox.scrollTop = ingameBox.scrollHeight;
applyIngameChatFade();
if (pointsPanelVisible) renderPointsPanel();
syncReplayStatusLobbyRows();
}
function injectLobbyChatCog() {
const chatBox = document.getElementById('newbonklobby_chatbox');
if (!chatBox) return;
const header = chatBox.querySelector('.newbonklobby_boxtop.newbonklobby_boxtop_classic');
if (!header) return;
if (!header.style.position) header.style.position = 'relative';
if (header.dataset.tbcChatInit === '1') {
return;
}
let btn = header.querySelector('#tbc_chat_cogbtn');
if (!btn) {
btn = document.createElement('button');
btn.id = 'tbc_chat_cogbtn';
btn.type = 'button';
btn.setAttribute('aria-label', 'Chat settings');
btn.title = 'Chat settings';
btn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18" height="18"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-label="Settings" role="img">
<path d="M 10.358 2.136
A 10.000 10.000 0 0 1 13.642 2.136
L 13.603 5.083
A 7.100 7.100 0 0 1 15.757 5.976
L 17.814 3.864
A 10.000 10.000 0 0 1 20.136 6.186
L 18.024 8.243
A 7.100 7.100 0 0 1 18.917 10.397
L 21.864 10.358
A 10.000 10.000 0 0 1 21.864 13.642
L 18.917 13.603
A 7.100 7.100 0 0 1 18.024 15.757
L 20.136 17.814
A 10.000 10.000 0 0 1 17.814 20.136
L 15.757 18.024
A 7.100 7.100 0 0 1 13.603 18.917
L 13.642 21.864
A 10.000 10.000 0 0 1 10.358 21.864
L 10.397 18.917
A 7.100 7.100 0 0 1 8.243 18.024
L 6.186 20.136
A 10.000 10.000 0 0 1 3.864 17.814
L 5.976 15.757
A 7.100 7.100 0 0 1 5.083 13.603
L 2.136 13.642
A 10.000 10.000 0 0 1 2.136 10.358
L 5.083 10.397
A 7.100 7.100 0 0 1 5.976 8.243
L 3.864 6.186
A 10.000 10.000 0 0 1 6.186 3.864
L 8.243 5.976
A 7.100 7.100 0 0 1 10.397 5.083
L 10.358 2.136
Z" />
<circle cx="12" cy="12" r="3.2" />
</svg>
`;
}
const getSettingsContainer = () => document.getElementById('settingsContainer');
const getLobbyRoot = () => document.getElementById('newbonklobby');
const overlayWindowIds = [
'leaveconfirmwindowcontainer',
'hostleaveconfirmwindowcontainer',
'maploadwindowcontainer',
'mapsearchwindowcontainer',
'mapeditorwindowcontainer',
'newbonklobby_votewindow_container'
];
const getOverlayWindows = () =>
overlayWindowIds
.map((id) => document.getElementById(id))
.filter(Boolean);
const observedOverlays = new WeakSet();
const syncCogVisibility = () => {
const settingsContainer = getSettingsContainer();
const lobbyRoot = getLobbyRoot();
const lobbyVisible = (() => {
if (!lobbyRoot) return false;
const cs = window.getComputedStyle(lobbyRoot);
return (
cs.display !== 'none' &&
cs.visibility !== 'hidden' &&
cs.opacity !== '0' &&
lobbyRoot.getClientRects().length > 0
);
})();
if (!lobbyVisible) {
btn.style.display = 'none';
btn.style.opacity = '0';
btn.style.pointerEvents = 'none';
return;
} else {
btn.style.display = '';
}
const hasBlockingOverlay = getOverlayWindows().some((overlayEl) => {
const cs = window.getComputedStyle(overlayEl);
return (
cs.display !== 'none' &&
cs.visibility !== 'hidden' &&
cs.opacity !== '0' &&
overlayEl.getClientRects().length > 0
);
});
if (hasBlockingOverlay) {
btn.style.display = 'none';
btn.style.opacity = '0';
btn.style.pointerEvents = 'none';
return;
} else {
btn.style.display = '';
}
const settingsVisible = (() => {
if (!settingsContainer) return false;
const cs = window.getComputedStyle(settingsContainer);
return (
cs.display !== 'none' &&
cs.visibility !== 'hidden' &&
cs.opacity !== '0' &&
settingsContainer.getClientRects().length > 0
);
})();
if (settingsVisible) {
btn.style.display = 'none';
btn.style.opacity = '0';
btn.style.pointerEvents = 'none';
return;
}
btn.style.display = '';
btn.style.opacity = '1';
btn.style.pointerEvents = '';
};
syncCogVisibility();
const settingsElNow = getSettingsContainer();
if (settingsElNow) {
const visObs = new MutationObserver(() => syncCogVisibility());
visObs.observe(settingsElNow, { attributes: true, attributeFilter: ['style', 'class'] });
}
const lobbyElNow = getLobbyRoot();
if (lobbyElNow) {
const lobbyObs = new MutationObserver(() => syncCogVisibility());
lobbyObs.observe(lobbyElNow, { attributes: true, attributeFilter: ['style', 'class'] });
}
const bindOverlayObservers = () => {
getOverlayWindows().forEach((overlayEl) => {
if (observedOverlays.has(overlayEl)) return;
const overlayObs = new MutationObserver(() => syncCogVisibility());
overlayObs.observe(overlayEl, { attributes: true, attributeFilter: ['style', 'class'] });
observedOverlays.add(overlayEl);
});
};
bindOverlayObservers();
const overlayRootObs = new MutationObserver(() => {
bindOverlayObservers();
syncCogVisibility();
});
overlayRootObs.observe(document.body, { childList: true, subtree: true });
if (!btn.parentElement) header.appendChild(btn);
if (!document.getElementById('tbc_chat_cog_css')) {
const style = document.createElement('style');
style.id = 'tbc_chat_cog_css';
style.textContent = `
#tbc_chat_cogbtn{
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.18);
border: 1px solid rgba(255,255,255,0.22);
border-radius: 7px;
padding: 0;
margin: 0;
cursor: pointer;
color: #ffffff;
z-index: 1;
}
#tbc_chat_cogbtn:hover{ background: rgba(255,255,255,0.10); }
#tbc_chat_cogbtn:active{ transform: translateY(-50%) scale(0.98); }
#tbc_chat_cogbtn svg{ display:block; }
`;
document.head.appendChild(style);
}
if (btn.dataset.tbcBound !== '1') {
btn.dataset.tbcBound = '1';
btn.addEventListener('click', (e) => {
e.stopPropagation();
const settingsBtn = document.getElementById('pretty_top_settings');
if (settingsBtn) settingsBtn.click();
setTimeout(() => {
const moddedTab = document.querySelector('#mod_tabs .mod_tab_modded');
if (moddedTab) moddedTab.click();
setTimeout(() => {
const chatCatBtn = Array.from(document.querySelectorAll('#mod_cat_tabs .mod_cat_tab'))
.find((el) => (el.textContent || '').trim().toLowerCase() === 'chat');
if (chatCatBtn) chatCatBtn.click();
}, 50);
}, 50);
});
}
header.dataset.tbcChatInit = '1';
}
function setupChatObservers() {
const idsToWatch = [
'newbonklobby_chat_content',
'ingamechatcontent',
'newbonklobby_playerbox_elementcontainer',
'newbonklobby_playerbox_leftelementcontainer',
'newbonklobby_playerbox_rightelementcontainer',
'newbonklobby_specbox_elementcontainer',
];
idsToWatch.forEach((id) => {
waitForElement(id, (el) => {
const obs = new MutationObserver((mutations) => {
if (id.indexOf('playerbox') !== -1 || id.indexOf('specbox') !== -1) {
scheduleChatScan();
return;
}
for (const m of mutations) {
for (const node of m.removedNodes) {
if (!(node instanceof Element)) continue;
const row = resolveChatRow(node);
if (row && row === node) {
unregisterRowFromAuthorIndex(row);
}
node
.querySelectorAll('.ingamechatentry, .newbonklobby_chat_msg')
.forEach((r) => unregisterRowFromAuthorIndex(r));
}
for (const node of m.addedNodes) {
if (!(node instanceof Element)) continue;
if (id === 'newbonklobby_chat_content') {
const statuses = [];
if (node.classList && node.classList.contains('newbonklobby_chat_status')) statuses.push(node);
node.querySelectorAll('.newbonklobby_chat_status').forEach((x) => statuses.push(x));
for (const st of statuses) {
if (!st.dataset.tbcStatusSeenAt) st.dataset.tbcStatusSeenAt = String(Date.now());
const statusRow = resolveChatRow(st);
if (statusRow && hasSystemPrefix(st.textContent || statusRow.textContent || '')) {
applyConfiguredSystemColorToRow(statusRow, st.textContent || statusRow.textContent || '');
}
mirrorLobbyStatusToIngame(st);
const txt = String(st.textContent || '').trim().toLowerCase();
if (txt.indexOf('not recognised') !== -1 && st.dataset.tbcUnknownHelpInjected !== '1') {
st.dataset.tbcUnknownHelpInjected = '1';
const now = Date.now();
if (now - lastUnknownSlashHelpAt > 120) {
lastUnknownSlashHelpAt = now;
setTimeout(() => appendSlashHelpStatusRows(), 0);
}
break;
}
}
const mapReqRows = [];
const rootRow = resolveChatRow(node);
if (rootRow && isLobbyMapRequestRow(rootRow)) mapReqRows.push(rootRow);
node.querySelectorAll('.newbonklobby_mapsuggest_low, .newbonklobby_mapsuggest_high').forEach((el2) => {
const r = resolveChatRow(el2);
if (!r || !isLobbyMapRequestRow(r)) return;
if (!mapReqRows.includes(r)) mapReqRows.push(r);
});
for (const r of mapReqRows) {
if (!r.dataset.tbcStatusSeenAt) r.dataset.tbcStatusSeenAt = String(Date.now());
mirrorLobbyMapRequestRowToIngame(r);
}
if (node.classList && node.classList.contains('newbonklobby_chat_msg')) addTouchedRow(node);
node.querySelectorAll('.newbonklobby_chat_msg').forEach((x) => addTouchedRow(x));
node.querySelectorAll('.newbonklobby_chat_msg_name').forEach((x) => {
addTouchedRow(x.closest('.newbonklobby_chat_msg') || x.parentElement);
});
} else if (id === 'ingamechatcontent') {
const rowsToTouch = new Set();
const rootRow = resolveChatRow(node);
if (rootRow) rowsToTouch.add(rootRow);
node.querySelectorAll('.ingamechatentry, .ingamechatname, .ingamechatstatus, .ingamechatmessage, .ingamechattext').forEach((x) => {
const r = resolveChatRow(x);
if (r) rowsToTouch.add(r);
});
rowsToTouch.forEach((r) => {
addTouchedRow(r);
mirrorIngameStatusToLobby(r);
});
}
}
if (id === 'newbonklobby_chat_content' && m.type === 'characterData') {
const carrier = m.target && m.target.parentElement ? m.target.parentElement : null;
const st = carrier && carrier.closest ? carrier.closest('.newbonklobby_chat_status') : null;
if (st) {
if (!st.dataset.tbcStatusSeenAt) st.dataset.tbcStatusSeenAt = String(Date.now());
delete st.dataset.tbcMirroredIngame;
mirrorLobbyStatusToIngame(st);
}
const row = carrier && carrier.closest ? carrier.closest('#newbonklobby_chat_content > *') : null;
if (row && isLobbyMapRequestRow(row)) {
if (!row.dataset.tbcStatusSeenAt) row.dataset.tbcStatusSeenAt = String(Date.now());
delete row.dataset.tbcMirroredIngame;
mirrorLobbyMapRequestRowToIngame(row);
}
}
if (id === 'ingamechatcontent' && m.type === 'characterData') {
const carrier = m.target && m.target.parentElement ? m.target.parentElement : null;
const row = carrier ? resolveChatRow(carrier) : null;
if (row) {
addTouchedRow(row);
mirrorIngameStatusToLobby(row);
}
}
if (id === 'newbonklobby_chat_content' && m.type === 'attributes') {
const targetEl = m.target instanceof Element ? m.target : null;
if (!targetEl) continue;
const st =
(targetEl.classList && targetEl.classList.contains('newbonklobby_chat_status') && targetEl) ||
(targetEl.closest ? targetEl.closest('.newbonklobby_chat_status') : null);
if (st) {
if (!st.dataset.tbcStatusSeenAt) st.dataset.tbcStatusSeenAt = String(Date.now());
mirrorLobbyStatusToIngame(st);
}
const row = resolveChatRow(targetEl);
if (row && isLobbyMapRequestRow(row)) {
if (!row.dataset.tbcStatusSeenAt) row.dataset.tbcStatusSeenAt = String(Date.now());
mirrorLobbyMapRequestRowToIngame(row);
}
}
if (id === 'ingamechatcontent' && m.type === 'attributes') {
const targetEl = m.target instanceof Element ? m.target : null;
if (!targetEl) continue;
const row = resolveChatRow(targetEl);
if (row) {
addTouchedRow(row);
mirrorIngameStatusToLobby(row);
}
}
if (id === 'newbonklobby_chat_content' && m.type === 'childList') {
const targetEl = m.target instanceof Element ? m.target : null;
if (!targetEl || targetEl.id === 'newbonklobby_chat_content') continue;
const st =
(targetEl && targetEl.classList && targetEl.classList.contains('newbonklobby_chat_status') && targetEl) ||
(targetEl && targetEl.querySelector ? targetEl.querySelector(':scope > .newbonklobby_chat_status') : null);
if (st) {
if (!st.dataset.tbcStatusSeenAt) st.dataset.tbcStatusSeenAt = String(Date.now());
mirrorLobbyStatusToIngame(st);
}
}
if (id === 'ingamechatcontent' && m.type === 'childList') {
const targetEl = m.target instanceof Element ? m.target : null;
if (!targetEl) continue;
const row = resolveChatRow(targetEl);
if (row) {
addTouchedRow(row);
mirrorIngameStatusToLobby(row);
}
}
}
scheduleTouchedRowsFlush();
if (id === 'ingamechatcontent') scheduleIngameVisualRefresh();
});
obs.observe(el, {
childList: true,
subtree: true,
characterData: true
});
scheduleChatScan();
if (id === 'ingamechatcontent') {
applyIngameChatBackgroundSetting();
syncLobbyStatusesToIngame(180);
scheduleIngameVisualRefresh();
}
injectLobbyChatCog();
});
});
const root = document.documentElement || document.body;
if (root) {
const rootObs = new MutationObserver(() => injectLobbyChatCog());
rootObs.observe(root, { childList: true, subtree: true });
}
window.addEventListener('tbcChatSettingsChanged', () => {
if (chatState.showSystemMessages) restoreStashedIngameSystemRows();
refreshVisibleSystemMessageRows();
syncReplayStatusLobbyRows();
scheduleChatScan();
scheduleIngameVisualRefresh();
});
}
let chatModRegistered = false;
function initChatMod() {
if (chatModRegistered) return;
const bonkMods = window.bonkMods;
if (!bonkMods) return;
chatModRegistered = true;
ensureChatStyles();
ensureGroupsPanelElement();
ensurePointsPanelElement();
bonkMods.registerMod({
id: 'tbc_chat',
name: 'Chat Tools',
version: '1.3.2',
author: 'SIoppy',
description: 'Blacklisted-user hiding, optional guest filtering (not quickplay), and extended in-game chat history.',
});
bonkMods.registerCategory({
id: 'chat_main',
label: 'Chat',
order: 60,
});
bonkMods.addBlock({
id: 'tbc_chat_block',
modId: 'tbc_chat',
categoryId: 'chat_main',
title: 'Chat',
order: 0,
render(container) {
updateChatStorageKey();
container.innerHTML = `
<div class="mod_block_sub">
Hide messages from blacklisted users and optionally hide guest messages.
<br><small style="opacity:.8;">If verification data is unavailable mid-game, hide-guests temporarily hides non-self user messages until lobby/player data is available.</small>
</div>
<div id="tbc_chat_storage_hint" style="margin-top:6px;font-size:11px;"></div>
<div class="tbc_row" style="margin-top:10px;">
<div class="tbc_toggle" id="tbc_toggle_hideguests">
<div class="tbc_toggle_dot"></div>
<div>
<div style="font-weight:800;">Hide guest messages</div>
<div style="font-size:10px;opacity:.8;">
Only works when guests can be verified (not quickplay)
</div>
</div>
</div>
<div class="tbc_toggle" id="tbc_toggle_showsystem">
<div class="tbc_toggle_dot"></div>
<div>
<div style="font-weight:800;">Show system messages</div>
<div style="font-size:10px;opacity:.8;">
Controls status lines like join/leave, command output, and countdown notices
</div>
</div>
</div>
<div class="tbc_toggle" id="tbc_toggle_ingamechatbg">
<div class="tbc_toggle_dot"></div>
<div>
<div style="font-weight:800;">In-game chat backgrounds</div>
<div style="font-size:10px;opacity:.8;">
Toggle row background fill in in-game chat
</div>
</div>
</div>
<div class="tbc_toggle" id="tbc_toggle_ingame_hide_others_delay">
<div class="tbc_toggle_dot"></div>
<div>
<div style="font-weight:800;">Hide others until fade delay</div>
<div style="font-size:10px;opacity:.8;">
In-game: only your own messages stay visible before fade delay
</div>
</div>
</div>
</div>
<div class="tbc_box">
<div class="tbc_h">In-game chat size</div>
<div class="tbc_p">Set how many lines are visible in the in-game chat box (default: 4, min: 1, max: 10).</div>
<div class="tbc_inputrow">
<input class="tbc_input" id="tbc_ingame_chat_lines" type="number" min="1" max="10" step="1" style="width:90px;" placeholder="1-10" title="Range: 1 to 10">
</div>
<div class="tbc_p" style="margin-top:8px;">
Message fade delay (seconds). Messages fade oldest-first after this delay (default: 8, min: 1, max: 60).
</div>
<div class="tbc_inputrow">
<input class="tbc_input" id="tbc_ingame_fade_delay" type="number" min="1" max="60" step="0.5" style="width:90px;" placeholder="8" title="Fade delay in seconds">
</div>
</div>
<div class="tbc_box">
<div class="tbc_h">Blacklist users</div>
<div class="tbc_p">Messages from these usernames are always hidden from chat.</div>
<div class="tbc_inputrow">
<input class="tbc_input" id="tbc_bl_user" type="text" placeholder="username…">
<div class="tbc_btn" id="tbc_bl_user_add">Add</div>
</div>
<div class="tbc_chips" id="tbc_bl_user_list"></div>
</div>
<div class="tbc_box">
<div class="tbc_toggle" id="tbc_toggle_systemcolors" style="margin-bottom:8px;">
<div class="tbc_toggle_dot"></div>
<div style="font-weight:800;">Custom system message colours</div>
</div>
<div class="tbc_syscolor_head">
<div class="tbc_h" style="margin-bottom:0;">System message colours</div>
<div class="tbc_syscolor_reset" id="tbc_syscolor_reset" title="Reset all colours">↻</div>
</div>
<div class="tbc_p">Set colour per system-message category. Format button cycles HEX -> RGB -> HSV.</div>
<div id="tbc_syscolor_list"></div>
</div>
`;
updateChatStorageHintUI();
const elHideGuests = $('tbc_toggle_hideguests');
const elShowSystem = $('tbc_toggle_showsystem');
const elIngameChatBg = $('tbc_toggle_ingamechatbg');
const elHideOthersDelay = $('tbc_toggle_ingame_hide_others_delay');
const elSystemColors = $('tbc_toggle_systemcolors');
const elIngameLines = $('tbc_ingame_chat_lines');
const elIngameFadeDelay = $('tbc_ingame_fade_delay');
const elSystemColorList = $('tbc_syscolor_list');
const elSystemColorReset = $('tbc_syscolor_reset');
function renderToggles() {
if (elHideGuests) elHideGuests.classList.toggle('on', !!chatState.hideGuests);
if (elShowSystem) elShowSystem.classList.toggle('on', !!chatState.showSystemMessages);
if (elIngameChatBg) elIngameChatBg.classList.toggle('on', !!chatState.ingameChatBackgrounds);
if (elHideOthersDelay) elHideOthersDelay.classList.toggle('on', !!chatState.hideIngameOthersUntilFadeDelay);
if (elSystemColors) elSystemColors.classList.toggle('on', !!chatState.useCustomSystemMessageColors);
if (elSystemColorList) {
const enabled = !!chatState.useCustomSystemMessageColors;
elSystemColorList.style.opacity = enabled ? '1' : '0.55';
elSystemColorList.style.pointerEvents = enabled ? '' : 'none';
}
if (elSystemColorReset) {
const enabled = !!chatState.useCustomSystemMessageColors;
elSystemColorReset.style.opacity = enabled ? '1' : '0.55';
elSystemColorReset.style.pointerEvents = enabled ? '' : 'none';
}
}
if (elHideGuests) {
elHideGuests.addEventListener('click', () => {
mutateChatState((state) => {
state.hideGuests = !state.hideGuests;
});
renderToggles();
});
}
if (elShowSystem) {
elShowSystem.addEventListener('click', () => {
mutateChatState((state) => {
state.showSystemMessages = !state.showSystemMessages;
});
if (chatState.showSystemMessages) restoreStashedIngameSystemRows();
renderToggles();
});
}
if (elIngameChatBg) {
elIngameChatBg.addEventListener('click', () => {
mutateChatState((state) => {
state.ingameChatBackgrounds = !state.ingameChatBackgrounds;
});
renderToggles();
applyIngameChatBackgroundSetting();
});
}
if (elHideOthersDelay) {
elHideOthersDelay.addEventListener('click', () => {
mutateChatState((state) => {
state.hideIngameOthersUntilFadeDelay = !state.hideIngameOthersUntilFadeDelay;
});
renderToggles();
scheduleIngameVisualRefresh();
});
}
if (elSystemColors) {
elSystemColors.addEventListener('click', () => {
mutateChatState((state) => {
state.useCustomSystemMessageColors = !state.useCustomSystemMessageColors;
});
renderToggles();
refreshVisibleSystemMessageRows();
scheduleChatScan();
});
}
renderToggles();
function renderIngameLines() {
if (!elIngameLines) return;
if (document.activeElement === elIngameLines) return;
elIngameLines.value = String(clampIngameChatLines(chatState.ingameChatLines));
}
function renderIngameFadeDelay() {
if (!elIngameFadeDelay) return;
if (document.activeElement === elIngameFadeDelay) return;
elIngameFadeDelay.value = String(clampIngameFadeDelaySec(chatState.ingameFadeDelaySec));
}
if (elIngameLines) {
const saveIngameLines = () => {
const val = clampIngameChatLines(elIngameLines.value);
mutateChatState((state) => {
state.ingameChatLines = val;
});
elIngameLines.value = String(val);
scheduleIngameVisualRefresh();
};
elIngameLines.addEventListener('change', saveIngameLines);
elIngameLines.addEventListener('blur', saveIngameLines);
}
if (elIngameFadeDelay) {
const saveIngameFadeDelay = () => {
const val = clampIngameFadeDelaySec(elIngameFadeDelay.value);
mutateChatState((state) => {
state.ingameFadeDelaySec = val;
});
elIngameFadeDelay.value = String(val);
scheduleIngameVisualRefresh();
};
elIngameFadeDelay.addEventListener('change', saveIngameFadeDelay);
elIngameFadeDelay.addEventListener('blur', saveIngameFadeDelay);
}
renderIngameLines();
renderIngameFadeDelay();
scheduleIngameVisualRefresh();
function renderBLUsers() {
const host = $('tbc_bl_user_list');
if (!host) return;
host.textContent = '';
chatState.blacklistUsers.forEach((u) => {
const chip = document.createElement('div');
chip.className = 'tbc_chip';
chip.innerHTML = `<span>${u}</span><span class="tbc_chip_x" title="Remove">×</span>`;
chip.querySelector('.tbc_chip_x').addEventListener('click', () => {
mutateChatState((state) => {
state.blacklistUsers = state.blacklistUsers.filter(
(x) => normalizeName(x) !== normalizeName(u)
);
});
renderBLUsers();
});
host.appendChild(chip);
});
}
function renderSystemMessageColors() {
const host = $('tbc_syscolor_list');
if (!host) return;
host.textContent = '';
chatState.systemMessageColors = normalizeSystemMessageColors(chatState.systemMessageColors);
const cfg = chatState.systemMessageColors;
SYSTEM_COLOR_CATEGORIES.forEach((cat) => {
const pref = cfg[cat.id] || { hex: SYSTEM_COLOR_DEFAULT_HEX[cat.id] || SYSTEM_COLOR_DEFAULT_HEX.defaultSystem, format: 'hex' };
const mode = pref.format || 'hex';
const nextMode = SYSTEM_COLOR_NEXT_FORMAT[mode] || 'hex';
const baseHex = normalizeHexColor(pref.hex) || SYSTEM_COLOR_DEFAULT_HEX[cat.id] || SYSTEM_COLOR_DEFAULT_HEX.defaultSystem;
const rgb = hexToRgbObj(baseHex) || { r: 0, g: 0, b: 0 };
const hsv = rgbToHsvObj(rgb.r, rgb.g, rgb.b);
const hexPlain = baseHex.replace('#', '');
const hInt = Math.round(hsv.h);
const sInt = Math.round(hsv.s * 100);
const vInt = Math.round(hsv.v * 100);
const row = document.createElement('div');
row.className = 'tbc_inputrow';
row.style.marginBottom = '8px';
row.style.display = 'grid';
row.style.gridTemplateColumns = '100px minmax(0, 1fr) 18px auto';
row.style.gap = '4px';
row.style.alignItems = 'center';
row.style.minWidth = '0';
row.style.width = '100%';
let editorHtml = '';
if (mode === 'hex') {
editorHtml = `
<div style="display:flex;align-items:center;gap:3px;min-width:0;white-space:nowrap;overflow:hidden;">
<div style="font-family:monospace;opacity:.9;">#</div>
<input class="tbc_input" data-syscolor-hex="${cat.id}" type="text" value="${hexPlain}" style="width:96px;min-width:0;padding:4px 6px;">
</div>
`;
} else if (mode === 'rgb') {
editorHtml = `
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;white-space:nowrap;min-width:0;overflow:hidden;">
<div style="display:flex;align-items:center;gap:2px;"><span style="font-size:10px;opacity:.9;">R</span><input class="tbc_input tbc_no_spin" data-syscolor-r="${cat.id}" type="number" min="0" max="255" step="1" value="${rgb.r}" style="width:42px;min-width:42px;padding:4px 4px;"></div>
<div style="display:flex;align-items:center;gap:2px;"><span style="font-size:10px;opacity:.9;">G</span><input class="tbc_input tbc_no_spin" data-syscolor-g="${cat.id}" type="number" min="0" max="255" step="1" value="${rgb.g}" style="width:42px;min-width:42px;padding:4px 4px;"></div>
<div style="display:flex;align-items:center;gap:2px;"><span style="font-size:10px;opacity:.9;">B</span><input class="tbc_input tbc_no_spin" data-syscolor-b="${cat.id}" type="number" min="0" max="255" step="1" value="${rgb.b}" style="width:42px;min-width:42px;padding:4px 4px;"></div>
</div>
`;
} else {
editorHtml = `
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;white-space:nowrap;min-width:0;overflow:hidden;">
<div style="display:flex;align-items:center;gap:2px;"><span style="font-size:10px;opacity:.9;">H</span><input class="tbc_input tbc_no_spin" data-syscolor-h="${cat.id}" type="number" min="0" max="360" step="1" value="${hInt}" style="width:42px;min-width:42px;padding:4px 4px;"></div>
<div style="display:flex;align-items:center;gap:2px;"><span style="font-size:10px;opacity:.9;">S</span><input class="tbc_input tbc_no_spin" data-syscolor-s="${cat.id}" type="number" min="0" max="100" step="1" value="${sInt}" style="width:42px;min-width:42px;padding:4px 4px;"></div>
<div style="display:flex;align-items:center;gap:2px;"><span style="font-size:10px;opacity:.9;">V</span><input class="tbc_input tbc_no_spin" data-syscolor-v="${cat.id}" type="number" min="0" max="100" step="1" value="${vInt}" style="width:42px;min-width:42px;padding:4px 4px;"></div>
</div>
`;
}
row.innerHTML = `
<div style="font-size:11px;opacity:.92;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;">${cat.label}</div>
${editorHtml}
<div data-syscolor-preview="${cat.id}" title="${baseHex}" style="width:14px;height:14px;border-radius:4px;border:1px solid rgba(255,255,255,0.28);background:${baseHex};box-shadow: inset 0 0 0 1px rgba(0,0,0,0.25);cursor:pointer;"></div>
<div class="tbc_btn" data-syscolor-cycle="${cat.id}" title="Switch format" style="padding:4px 6px;white-space:nowrap;font-size:10px;">${mode.toUpperCase()}->${nextMode.toUpperCase()}</div>
`;
host.appendChild(row);
const btn = row.querySelector(`[data-syscolor-cycle="${cat.id}"]`);
const previewBtn = row.querySelector(`[data-syscolor-preview="${cat.id}"]`);
const saveHexForCategory = (parsedHex, opts = null) => {
if (!parsedHex) return;
const rerender = !(opts && opts.rerender === false);
mutateChatState((state) => {
state.systemMessageColors = normalizeSystemMessageColors(state.systemMessageColors);
state.systemMessageColors[cat.id].hex = parsedHex;
state.systemMessageColors[cat.id].format = mode;
});
if (rerender) renderSystemMessageColors();
refreshVisibleSystemMessageRows();
scheduleChatScan();
};
if (mode === 'hex') {
const inpHex = row.querySelector(`[data-syscolor-hex="${cat.id}"]`);
if (inpHex) {
const applyHex = (opts = null) => {
const parsedHex = parseColorTextToHex(`#${String(inpHex.value || '').trim()}`, 'hex');
if (!parsedHex) {
inpHex.value = hexPlain;
return;
}
saveHexForCategory(parsedHex, opts);
};
inpHex.addEventListener('input', () => applyHex({ rerender: false }));
inpHex.addEventListener('change', applyHex);
inpHex.addEventListener('blur', applyHex);
}
} else if (mode === 'rgb') {
const inpR = row.querySelector(`[data-syscolor-r="${cat.id}"]`);
const inpG = row.querySelector(`[data-syscolor-g="${cat.id}"]`);
const inpB = row.querySelector(`[data-syscolor-b="${cat.id}"]`);
const applyRgb = (opts = null) => {
if (!inpR || !inpG || !inpB) return;
const parsedHex = rgbObjToHex(inpR.value, inpG.value, inpB.value);
saveHexForCategory(parsedHex, opts);
};
if (inpR) { inpR.addEventListener('input', () => applyRgb({ rerender: false })); }
if (inpG) { inpG.addEventListener('input', () => applyRgb({ rerender: false })); }
if (inpB) { inpB.addEventListener('input', () => applyRgb({ rerender: false })); }
if (inpR) { inpR.addEventListener('change', applyRgb); inpR.addEventListener('blur', applyRgb); }
if (inpG) { inpG.addEventListener('change', applyRgb); inpG.addEventListener('blur', applyRgb); }
if (inpB) { inpB.addEventListener('change', applyRgb); inpB.addEventListener('blur', applyRgb); }
} else {
const inpH = row.querySelector(`[data-syscolor-h="${cat.id}"]`);
const inpS = row.querySelector(`[data-syscolor-s="${cat.id}"]`);
const inpV = row.querySelector(`[data-syscolor-v="${cat.id}"]`);
const applyHsv = (opts = null) => {
if (!inpH || !inpS || !inpV) return;
const rgbFromHsv = hsvToRgbObj(inpH.value, clamp01(parseFloat(String(inpS.value)) / 100), clamp01(parseFloat(String(inpV.value)) / 100));
const parsedHex = rgbObjToHex(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b);
saveHexForCategory(parsedHex, opts);
};
if (inpH) { inpH.addEventListener('input', () => applyHsv({ rerender: false })); }
if (inpS) { inpS.addEventListener('input', () => applyHsv({ rerender: false })); }
if (inpV) { inpV.addEventListener('input', () => applyHsv({ rerender: false })); }
if (inpH) { inpH.addEventListener('change', applyHsv); inpH.addEventListener('blur', applyHsv); }
if (inpS) { inpS.addEventListener('change', applyHsv); inpS.addEventListener('blur', applyHsv); }
if (inpV) { inpV.addEventListener('change', applyHsv); inpV.addEventListener('blur', applyHsv); }
}
if (btn) {
btn.addEventListener('click', () => {
const newMode = SYSTEM_COLOR_NEXT_FORMAT[mode] || 'hex';
mutateChatState((state) => {
state.systemMessageColors = normalizeSystemMessageColors(state.systemMessageColors);
state.systemMessageColors[cat.id].format = newMode;
});
renderSystemMessageColors();
});
}
if (previewBtn) {
previewBtn.addEventListener('click', (e) => {
e.stopPropagation();
const cfgNow = normalizeSystemMessageColors(chatState.systemMessageColors);
const currentHex =
normalizeHexColor((cfgNow[cat.id] && cfgNow[cat.id].hex) || '') ||
baseHex;
openPanel(previewBtn, (panel) => {
const presetSwatches = COLOR_PRESETS.map((p) => {
const active = p.color.toLowerCase() === currentHex.toLowerCase();
return `<div class="cg_swatch${active ? ' active' : ''}" data-preset="${p.id}" title="${p.label}" style="background:${p.color};"></div>`;
}).join('');
panel.innerHTML = `
<div class="cg_color_panel">
<div class="cg_color_panel_top">
<div class="cg_color_panel_preview">
<div class="cg_color_panel_previewbox" style="background:${currentHex};"></div>
<div class="cg_color_panel_hex">${currentHex.toLowerCase()}</div>
</div>
</div>
<div class="cg_color_panel_body">
<div class="cg_color_panel_section">
<div class="cg_color_panel_section_title">Presets</div>
<div class="cg_color_swatches">${presetSwatches}</div>
</div>
<div class="cg_color_panel_section">
<div class="cg_color_panel_section_title">Custom</div>
<div class="cg_color_custom_row">
<input class="cg_custom_picker" type="color" value="${currentHex}">
<div class="cg_color_usebtn">Use</div>
</div>
<div style="margin-top:6px;font-size:10px;opacity:.75;">Pick a colour, then press "Use".</div>
</div>
</div>
</div>
`;
const preview = panel.querySelector('.cg_color_panel_previewbox');
const hexEl = panel.querySelector('.cg_color_panel_hex');
const customPicker = panel.querySelector('.cg_custom_picker');
const useBtn = panel.querySelector('.cg_color_usebtn');
const setPreviewHex = (val) => {
if (preview) preview.style.background = val;
if (hexEl) hexEl.textContent = String(val || '').toLowerCase();
};
const applyHex = (hexVal) => {
const parsed = normalizeHexColor(hexVal);
if (!parsed) return;
mutateChatState((state) => {
state.systemMessageColors = normalizeSystemMessageColors(state.systemMessageColors);
state.systemMessageColors[cat.id].hex = parsed;
});
renderSystemMessageColors();
refreshVisibleSystemMessageRows();
scheduleChatScan();
};
panel.querySelectorAll('.cg_swatch').forEach((sw) => {
sw.addEventListener('click', () => {
const pid = sw.dataset.preset;
const p = COLOR_PRESETS.find((pp) => pp.id === pid);
if (!p) return;
panel.querySelectorAll('.cg_swatch').forEach((s2) => s2.classList.remove('active'));
sw.classList.add('active');
setPreviewHex(p.color);
applyHex(p.color);
});
});
if (customPicker) {
customPicker.addEventListener('input', () => {
panel.querySelectorAll('.cg_swatch').forEach((s2) => s2.classList.remove('active'));
setPreviewHex(customPicker.value);
});
}
if (useBtn) {
useBtn.addEventListener('click', () => {
if (!customPicker) return;
applyHex(customPicker.value);
closePanel();
});
}
});
});
}
});
}
const addUser = $('tbc_bl_user_add');
const syscolorResetBtn = $('tbc_syscolor_reset');
if (addUser) addUser.addEventListener('click', () => {
const inp = $('tbc_bl_user');
const u = inp ? inp.value.trim() : '';
if (!u) return;
const selfNorm = getSelfNameNorm();
const uNorm = normalizeName(u);
if (selfNorm && uNorm === selfNorm) {
if (inp) inp.value = '';
return;
}
let changed = false;
mutateChatState((state) => {
if (!state.blacklistUsers.some((x) => normalizeName(x) === uNorm)) {
state.blacklistUsers.push(u);
changed = true;
}
});
if (changed) renderBLUsers();
if (inp) inp.value = '';
});
if (syscolorResetBtn) {
syscolorResetBtn.addEventListener('click', () => {
mutateChatState((state) => {
const next = {};
SYSTEM_COLOR_CATEGORIES.forEach((cat) => {
const curMode =
state.systemMessageColors &&
state.systemMessageColors[cat.id] &&
state.systemMessageColors[cat.id].format
? state.systemMessageColors[cat.id].format
: 'hex';
next[cat.id] = {
hex: SYSTEM_COLOR_DEFAULT_HEX[cat.id] || SYSTEM_COLOR_DEFAULT_HEX.defaultSystem,
format: curMode,
};
});
state.systemMessageColors = next;
});
renderSystemMessageColors();
refreshVisibleSystemMessageRows();
scheduleChatScan();
});
}
refreshChatSettingsUi = () => {
updateChatStorageHintUI();
renderToggles();
renderIngameLines();
renderIngameFadeDelay();
renderSystemMessageColors();
renderBLUsers();
};
refreshChatSettingsUi();
renderBLUsers();
},
});
updateChatStorageKey();
setupChatObservers();
setupCustomSlashCommands();
setupIngameChatFocusGuards();
ensureTopAdClickthroughWatcher();
ensureChatAccountWatchers();
scheduleChatScan();
injectLobbyChatCog();
scheduleIngameVisualRefresh();
updateReplaySystemProtectionLatch();
document.addEventListener('visibilitychange', () => {
scheduleIngameVisualRefresh();
updateReplaySystemProtectionLatch();
});
window.addEventListener('focus', () => {
scheduleIngameVisualRefresh();
updateReplaySystemProtectionLatch();
});
window.addEventListener('blur', () => updateReplaySystemProtectionLatch());
window.addEventListener('resize', () => scheduleIngameVisualRefresh());
waitForElement('gamerenderer', (el) => {
const obs = new MutationObserver(() => updateReplaySystemProtectionLatch());
obs.observe(el, { attributes: true, attributeFilter: ['style', 'class'] });
});
waitForElement('newbonklobby', (el) => {
const obs = new MutationObserver(() => updateReplaySystemProtectionLatch());
obs.observe(el, { attributes: true, attributeFilter: ['style', 'class'] });
});
}
if (window.bonkMods) initChatMod();
window.addEventListener('bonkModsReady', initChatMod);
window.addEventListener('recolorGroupsChanged', () => {
refreshAllIndexedRows();
if (groupsPanelVisible) renderGroupsPanel();
queueGroupsPanelActionAutoSync();
});
window.addEventListener('tbcGroupMembershipChanged', (e) => {
const names = e && e.detail && Array.isArray(e.detail.players) ? e.detail.players : [];
if (names.length) refreshRowsForAuthors(names);
else refreshAllIndexedRows();
if (groupsPanelVisible) renderGroupsPanel();
queueGroupsPanelActionAutoSync();
});
window.addEventListener('tbcGroupColorChanged', (e) => {
const names = e && e.detail && Array.isArray(e.detail.players) ? e.detail.players : [];
if (names.length) refreshRowsForAuthors(names);
else refreshAllIndexedRows();
});
})();
waitForElement('pretty_top_level', () => {
waitForElement('pretty_top_name', () => {
ensureAccountObservers();
updateAccountStorageKey();
});
});
})();
})();