From 827e6eed7ae48146c35c9d7c66dd01b5ac8605fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Wed, 14 Jan 2026 01:26:13 +0100 Subject: [PATCH] Combo marketplace script --- doc/marketplace-scripts/emote-combo/logo.png | Bin 0 -> 1339 bytes .../emote-combo/metadata.json | 4 + doc/marketplace-scripts/emote-combo/source.js | 297 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 doc/marketplace-scripts/emote-combo/logo.png create mode 100644 doc/marketplace-scripts/emote-combo/metadata.json create mode 100644 doc/marketplace-scripts/emote-combo/source.js diff --git a/doc/marketplace-scripts/emote-combo/logo.png b/doc/marketplace-scripts/emote-combo/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9763feb72360c426055b508278e809b04cce865a GIT binary patch literal 1339 zcmcIje`r%z6#m}LOY_>M=1p{cv7>u-bsLH_ZP{$8ZC<6ic5YqfWDb#<(jPLZV>mX_ zS>sKG_=9S*!H_ZRoaht=D@=yJx|tCSvO(4AA6jXf5?#Szo3w6?ZKgZ76Ju=3C zW9_vKa!+?UV6}(#D!Cn*2s@jsOBM!NY5?~&?Oj9vy9?knB}E#og)dy%N|7M31~lU5 zPU3h;9^h!Q7VbZMM_>nb-?z+)y2Ug|E^-FJ20za6@^6c#HS|;$1 zR^&kEFAF~DKPVb*xAg~44Abn~h?kOMdilbWWY>6K>=^9aA(G|2BQjLZ<0fFlnRB)| zv83a9py+1ty)B{fYQR%mS;Y2iBGPkq)KyPp`Ib{nRoS+wCRAd3=T#Fwzp8NT!}T>D z`a9mpKWz73z58t)Dc4(jF#iYpgEs-=u4%=9z0caLz6`h&-%AqpYS29MilDpvzHz~9 zrS5q~A$@LT2~PD%qAlFy;=GOc2!$oPb6Ox);H9eMc?480Bxo%9)PNX-1l0AYWmbGf zzK|1BX~I2)7R3$H8j9w`23e-Qt)z>gI=oP1zYXQVgrtk0EJZC`r^lfx)`uY7(E^20 zO8e%+nT0Iea3`ign|B3==HQFk$e5Y`>WCFHqd}u5a41J@$%ZR6ZP<=97Al1? z7rD}mt^4*Le=S`~y67~q80`s0(DoT&Qt4Ga-SK(Xb88S8vp { + const name = emote?.name ? String(emote.name) : ""; + if (!name) { + return; + } + map.set(name, { + id: emote?.id ? String(emote.id) : name, + name, + url: emote?.url || null, + }); + }); + return map; +} + +function getEmoteBitmap(url, state) { + if (!url) { + return null; + } + const cache = state.emoteCache; + const existing = cache.get(url); + if (existing?.bitmap) { + return existing.bitmap; + } + if (existing?.loading) { + return null; + } + const entry = { loading: true, bitmap: null }; + cache.set(url, entry); + fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to load emote"); + } + return response.blob(); + }) + .then((blob) => createImageBitmap(blob)) + .then((bitmap) => { + entry.bitmap = bitmap; + entry.loading = false; + }) + .catch(() => { + cache.delete(url); + }); + return null; +} + +function addEmoteOccurrence(found, emoteKey, name, url, count = 1) { + if (!emoteKey) { + return; + } + const existing = found.get(emoteKey) || { key: emoteKey, name, url, count: 0 }; + existing.name = name || existing.name; + existing.url = url || existing.url; + existing.count += count; + found.set(emoteKey, existing); +} + +function extractEmotes(message, catalogMap) { + const found = new Map(); + const fragments = Array.isArray(message?.fragments) ? message.fragments : []; + let sawEmoteFragment = false; + + fragments.forEach((fragment) => { + if (fragment?.type === "emote") { + sawEmoteFragment = true; + const key = String(fragment?.id || fragment?.name || fragment?.text || fragment?.url || ""); + addEmoteOccurrence( + found, + key, + fragment?.name || fragment?.text || key, + fragment?.url || null, + 1, + ); + } + }); + + if (!sawEmoteFragment) { + const text = message?.message || fragments.map((fragment) => fragment?.text || "").join(""); + if (text && catalogMap.size) { + text.split(/\s+/).forEach((token) => { + if (!token) { + return; + } + const catalogEntry = catalogMap.get(token); + if (!catalogEntry) { + return; + } + addEmoteOccurrence(found, catalogEntry.id, catalogEntry.name, catalogEntry.url, 1); + }); + } + } + + return found; +} + +function updateComboForMessage(message, state, catalogMap) { + const emotesInMessage = extractEmotes(message, catalogMap); + state.messageIndex += 1; + const now = message?.timestamp || Date.now(); + + if (!emotesInMessage.size) { + if (state.previousMessageKeys) { + state.previousMessageKeys.forEach((key) => { + const stats = state.emoteStats.get(key); + if (stats && stats.lastMessageIndex === state.messageIndex - 1) { + stats.streak = 0; + state.emoteStats.set(key, stats); + } + }); + } + state.previousMessageKeys = new Set(); + return; + } + const messageKeys = new Set(emotesInMessage.keys()); + + emotesInMessage.forEach((emote) => { + const key = emote.key; + const stats = state.emoteStats.get(key) || { + key, + name: emote.name, + url: emote.url, + streak: 0, + lastMessageIndex: 0, + comboCount: 0, + lastSeenTime: 0, + }; + stats.name = emote.name || stats.name; + stats.url = emote.url || stats.url; + + if (stats.lastMessageIndex === state.messageIndex - 1) { + stats.streak += 1; + } else { + stats.streak = 1; + stats.comboCount = 0; + } + + stats.lastMessageIndex = state.messageIndex; + stats.lastSeenTime = now; + + if (stats.streak >= COMBO_MESSAGE_REQUIREMENT) { + if (!state.activeComboKey || state.activeComboKey === key) { + if (state.activeComboKey !== key) { + stats.comboCount = stats.streak; + state.activeComboKey = key; + } else { + stats.comboCount += 1; + } + state.activeComboLastSeen = now; + state.lastCombo = { + key, + name: stats.name, + url: stats.url, + count: stats.comboCount, + }; + state.shakeUntil = now + SHAKE_DURATION_MS; + } + } + + state.emoteStats.set(key, stats); + }); + + if (state.previousMessageKeys) { + state.previousMessageKeys.forEach((key) => { + if (messageKeys.has(key)) { + return; + } + const stats = state.emoteStats.get(key); + if (stats && stats.lastMessageIndex === state.messageIndex - 1) { + stats.streak = 0; + state.emoteStats.set(key, stats); + } + }); + } + + state.previousMessageKeys = messageKeys; +} + +function processNewMessages(messages, state, catalogMap) { + const freshMessages = messages.filter((message) => { + const timestamp = message?.timestamp || 0; + return timestamp > state.lastProcessedTimestamp; + }); + + freshMessages.forEach((message) => { + updateComboForMessage(message, state, catalogMap); + state.lastProcessedTimestamp = Math.max(state.lastProcessedTimestamp, message.timestamp || 0); + }); +} + +function resolveDisplayedCombo(state, now) { + if (state.activeComboKey) { + const stats = state.emoteStats.get(state.activeComboKey); + if (stats && now - stats.lastSeenTime <= COMBO_IDLE_TIMEOUT_SECONDS * 1000) { + return { + name: stats.name, + url: stats.url, + count: stats.comboCount, + active: true, + }; + } + state.activeComboKey = null; + state.comboEndedAt = now; + if (state.lastCombo) { + state.lastCombo = { ...state.lastCombo }; + } + } + + if (state.comboEndedAt && now - state.comboEndedAt <= COMBO_PERSIST_SECONDS * 1000) { + return state.lastCombo ? { ...state.lastCombo, active: false } : null; + } + + return null; +} + +function drawCombo(context, state, combo, now) { + const { ctx, width, height } = context; + if (!ctx || !combo) { + return; + } + + ctx.clearRect(0, 0, width, height); + ctx.font = FONT; + ctx.textBaseline = "middle"; + + let shakeX = 0; + let shakeY = 0; + if (state.shakeUntil && now < state.shakeUntil) { + shakeX = (Math.random() * 2 - 1) * SHAKE_MAGNITUDE; + shakeY = (Math.random() * 2 - 1) * SHAKE_MAGNITUDE; + } + + const iconX = PADDING + shakeX; + const iconY = height - PADDING - ICON_SIZE + shakeY; + const labelX = iconX + ICON_SIZE + PADDING; + const labelY = iconY + ICON_SIZE / 2; + + const bitmap = getEmoteBitmap(combo.url, state); + if (bitmap) { + ctx.drawImage(bitmap, iconX, iconY, ICON_SIZE, ICON_SIZE); + } else if (combo.name) { + ctx.fillStyle = LABEL_COLOR; + ctx.fillText(combo.name, iconX, labelY); + } + + ctx.fillStyle = LABEL_COLOR; + ctx.fillText(`${combo.count} x Combo`, labelX, labelY); +} + +function tick(context, state) { + const { chatMessages, emoteCatalog } = context; + if (!context?.ctx) { + return; + } + ensureState(state); + const messages = Array.isArray(chatMessages) ? chatMessages : []; + const catalogMap = buildCatalogMap(emoteCatalog); + processNewMessages(messages, state, catalogMap); + + const now = Date.now(); + const combo = resolveDisplayedCombo(state, now); + if (!combo) { + context.ctx.clearRect(0, 0, context.width, context.height); + return; + } + drawCombo(context, state, combo, now); +}