From 32a4d13aaa77c1ab9ca1b1fce9ce0b28868dec9c Mon Sep 17 00:00:00 2001 From: Ars_Mond Date: Mon, 27 Jul 2026 03:25:04 +0500 Subject: [PATCH 1/6] feat: support Wordgun API v2 with model, difficulty and WebSocket guesses --- index.html | 13 ++++ js/api.js | 66 ++++++++++++++--- js/config.js | 2 + js/init.js | 1 + js/settings.js | 105 +++++++++++++++++++++++++-- js/wordgun_ws.js | 182 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 js/wordgun_ws.js diff --git a/index.html b/index.html index 7509125..01da6b1 100644 --- a/index.html +++ b/index.html @@ -111,6 +111,18 @@ + + + diff --git a/js/api.js b/js/api.js index d17e931..9c7d622 100644 --- a/js/api.js +++ b/js/api.js @@ -122,7 +122,7 @@ async function sendWebhookEvent(event = '', data = {}) { // score(gameId, word) -> { distance } (distance falsy => not in vocabulary) // tip(gameId, lastRank) -> { word, distance } (optional; null if unsupported) -const WORDGUN_BASE_URL = 'https://api.wordgun.ru/v1'; +const WORDGUN_BASE_URL = 'https://api.wordgun.ru'; async function wordgun_request(path, { method = 'GET', body = null } = {}) { const controller = new AbortController(); @@ -225,9 +225,11 @@ const GAME_BACKENDS = { } }, - // wordgun.ru — stateless API (see public-api-v1.md). The game lives inside an + // wordgun.ru — stateless API (see public-api-v2.md). The game lives inside an // opaque token; a guess returns { in_vocab, rank }. The secret word is never // disclosed, but a hint endpoint reveals a word closer than your best rank. + // v2 adds model & difficulty selection and a WebSocket guess channel; guesses + // fall back to the v1 HTTP endpoint, which accepts v2 tokens unchanged. wordgun: { id: 'wordgun', label: 'wordgun.ru', @@ -235,19 +237,50 @@ const GAME_BACKENDS = { maxDistance: Infinity, async createGame() { - const data = await wordgun_request('/games', { method: 'POST' }); + // Both fields are optional: an empty model means the server default, + // an empty difficulty means the secret is drawn from the whole vocabulary. + const body = {}; + if (wordgun_model) body.model = wordgun_model; + if (wordgun_difficulty) body.difficulty = wordgun_difficulty; + + const data = await wordgun_request('/v2/create_game', { method: 'POST', body }); if (!data?.token) { throw new Error('Wordgun API не вернул токен игры'); } + + // One game per socket — retarget the guess channel at the new token and + // warm it up, so the first chat word does not pay the connection latency + // (nor the open timeout when WebSockets turn out to be unreachable). + wordgun_ws_set_game(data.token); + if (wordgun_ws_available(data.token)) { + wordgun_ws_ensure_open().catch(() => {}); + } + // Wordgun never reveals the secret word, so it stays null. return { gameId: data.token, secretWord: null }; }, async score(gameId, word) { - const result = await wordgun_request('/guess', { - method: 'POST', - body: { token: gameId, word: word } - }); + let result = null; + + if (wordgun_ws_available(gameId)) { + try { + result = await wordgun_ws_guess(gameId, word); + } catch (error) { + // A rejection from the server itself (bad or expired token) would + // fail over HTTP too — only the transport is worth retrying. + if (error.wordgun_rejected) throw error; + console.warn('Wordgun WebSocket недоступен, отправляю через HTTP:', error); + } + } + + if (!result) { + result = await wordgun_request('/v1/guess', { + method: 'POST', + body: { token: gameId, word: word } + }); + } + // Normalize to the shared { distance } shape: rank is the distance, // an out-of-vocabulary guess has no distance. return { distance: result.in_vocab ? result.rank : undefined }; @@ -259,13 +292,28 @@ const GAME_BACKENDS = { // we have a finite best, so the server returns a far first hint. const body = { token: gameId }; if (Number.isFinite(bestRank)) body.best_rank = bestRank; - const result = await wordgun_request('/hint', { method: 'POST', body }); + const result = await wordgun_request('/v2/hint', { method: 'POST', body }); // { word: null } means no closer word remains. return { word: result?.word ?? null, distance: result?.rank }; } } }; +// The models and difficulties available on the server, fetched once and cached. +let wordgun_models_cache = null; + +async function wordgun_list_models() { + if (wordgun_models_cache) return wordgun_models_cache; + + const data = await wordgun_request('/v2/list_model'); + wordgun_models_cache = { + models: Array.isArray(data?.models) ? data.models : [], + defaultModel: data?.default || '', + difficulties: Array.isArray(data?.difficulties) ? data.difficulties : [] + }; + return wordgun_models_cache; +} + function getActiveBackend() { return GAME_BACKENDS[game_backend] || GAME_BACKENDS.kontekstno; } @@ -295,6 +343,8 @@ async function get_tip(gameId, lastRank) { async function generate_secret_word() { const backend = getActiveBackend(); + // Release the wordgun guess socket whenever another backend takes over. + if (backend.id !== 'wordgun') wordgun_ws_close(); let retry_count = 0; const max_retries = 5; diff --git a/js/config.js b/js/config.js index 5a8c55b..c76fb8f 100644 --- a/js/config.js +++ b/js/config.js @@ -6,6 +6,8 @@ let sound_enable = true; let webhook_url = ''; let webhook_secret = ''; let game_backend = 'kontekstno'; // active word-guessing backend: 'kontekstno' | 'wordgun' +let wordgun_model = ''; // wordgun v2 model; empty = server default +let wordgun_difficulty = ''; // wordgun v2 difficulty; empty = whole vocabulary let current_secret_word_data = null; // Состояние игры diff --git a/js/init.js b/js/init.js index 7850dfe..3037ced 100644 --- a/js/init.js +++ b/js/init.js @@ -124,6 +124,7 @@ async function app() { } else { document.getElementById('settings').style.display = 'block'; + if (game_backend === 'wordgun') loadWordgunOptions(); } } catch (error) { diff --git a/js/settings.js b/js/settings.js index 0720e0d..93c20a3 100644 --- a/js/settings.js +++ b/js/settings.js @@ -6,7 +6,11 @@ const saveBtn = document.getElementById('save-settings-btn'); const obsLinkInput = document.getElementById('obs-link'); const gameBackendInput = document.getElementById('game-backend'); const backendWarning = document.getElementById('backend-warning'); +const wordgunModelInput = document.getElementById('wordgun-model'); +const wordgunDifficultyInput = document.getElementById('wordgun-difficulty'); +const wordgunSettingBlocks = document.querySelectorAll('.wordgun-setting'); let validationTimeout; +let wordgunOptionsLoaded = false; function parseBooleanSetting(value) { return String(value).toLowerCase() === 'true' || String(value) === '1'; @@ -46,6 +50,17 @@ function generateObsLink() { if (gameBackendInput) { params.set('backend', gameBackendInput.value); + + // wordgun v2 model/difficulty: only meaningful for that backend, and an + // empty value already means "server default" / "whole vocabulary". + if (gameBackendInput.value === 'wordgun') { + if (wordgunModelInput && wordgunModelInput.value) { + params.set('wg_model', wordgunModelInput.value); + } + if (wordgunDifficultyInput && wordgunDifficultyInput.value) { + params.set('wg_difficulty', wordgunDifficultyInput.value); + } + } } if (webhook_url) { @@ -161,7 +176,15 @@ function loadSettings() { game_backend = storedBackend; } if (gameBackendInput) gameBackendInput.value = game_backend; - updateBackendWarning(); + + wordgun_model = (getSettingValue(urlParams, 'wg_model', 'wordgun_model') || '').trim(); + wordgun_difficulty = (getSettingValue(urlParams, 'wg_difficulty', 'wordgun_difficulty') || '').trim(); + // Show the stored values right away; the real option lists arrive from + // GET /v2/list_model only once the settings panel is actually opened. + ensureSelectOption(wordgunModelInput, wordgun_model); + ensureSelectOption(wordgunDifficultyInput, wordgun_difficulty); + + updateBackendSettings(); // Генерируем ссылку OBS при загрузке страницы generateObsLink(); @@ -192,6 +215,16 @@ if (saveBtn) { localStorage.setItem('game_backend', game_backend); } + if (wordgunModelInput) { + wordgun_model = wordgunModelInput.value; + localStorage.setItem('wordgun_model', wordgun_model); + } + + if (wordgunDifficultyInput) { + wordgun_difficulty = wordgunDifficultyInput.value; + localStorage.setItem('wordgun_difficulty', wordgun_difficulty); + } + // Генерируем ссылку для OBS generateObsLink(); @@ -275,7 +308,12 @@ if (restartInput) { document.getElementById('menu-button-settings').addEventListener('click', () => { const settingsSection = document.getElementById('settings'); - settingsSection.style.display = settingsSection.style.display === 'none' ? 'block' : 'none'; + const willOpen = settingsSection.style.display === 'none'; + settingsSection.style.display = willOpen ? 'block' : 'none'; + + if (willOpen && (gameBackendInput ? gameBackendInput.value : game_backend) === 'wordgun') { + loadWordgunOptions(); + } }); if (avatarInput) { @@ -290,21 +328,74 @@ if (soundInput) { }); } -// Show a warning when the wordgun backend is selected (it can be region-blocked from some RU IPs). -function updateBackendWarning() { - if (!backendWarning) return; +// Show the wordgun-only settings and its warning (the API can be region-blocked +// from some RU IPs) only while that backend is selected. +function updateBackendSettings() { const selected = gameBackendInput ? gameBackendInput.value : game_backend; - backendWarning.style.display = selected === 'wordgun' ? 'block' : 'none'; + const isWordgun = selected === 'wordgun'; + + if (backendWarning) { + backendWarning.style.display = isWordgun ? 'block' : 'none'; + } + + wordgunSettingBlocks.forEach((block) => { + block.style.display = isWordgun ? 'block' : 'none'; + }); +} + +// Keep a stored value selectable even before (or without) the option list — +// otherwise a saved model would silently reset to the default. +function ensureSelectOption(select, value) { + if (!select || !value) return; + const exists = Array.from(select.options).some((option) => option.value === value); + if (!exists) select.add(new Option(value, value)); + select.value = value; +} + +function fillSelectOptions(select, values, current, emptyLabel) { + if (!select) return; + select.innerHTML = ''; + select.add(new Option(emptyLabel, '')); + values.forEach((value) => select.add(new Option(value, value))); + if (current && !values.includes(current)) { + select.add(new Option(`${current} (недоступно)`, current)); + } + select.value = current || ''; +} + +// Pull the models and difficulties from GET /v2/list_model. Called lazily so the +// OBS overlay — which reads everything from the URL — never hits the endpoint. +async function loadWordgunOptions() { + if (wordgunOptionsLoaded || !wordgunModelInput || !wordgunDifficultyInput) return; + + try { + const info = await wordgun_list_models(); + wordgunOptionsLoaded = true; + + const defaultLabel = info.defaultModel ? `По умолчанию (${info.defaultModel})` : 'По умолчанию'; + fillSelectOptions(wordgunModelInput, info.models, wordgun_model, defaultLabel); + fillSelectOptions(wordgunDifficultyInput, info.difficulties, wordgun_difficulty, 'Без ограничения'); + } catch (error) { + // A failed lookup must not wipe the saved settings, so keep what we have. + console.warn('Не удалось загрузить список моделей wordgun:', error); + ensureSelectOption(wordgunModelInput, wordgun_model); + ensureSelectOption(wordgunDifficultyInput, wordgun_difficulty); + } } if (gameBackendInput) { gameBackendInput.addEventListener("change", () => { - updateBackendWarning(); + updateBackendSettings(); + if (gameBackendInput.value === 'wordgun') loadWordgunOptions(); generateObsLink(); checkFormsValidity(); }); } +[wordgunModelInput, wordgunDifficultyInput].forEach((select) => { + if (select) select.addEventListener("change", generateObsLink); +}); + // Копирование ссылки для OBS при клике на иконку const copyIcon = document.querySelector('.copy-icon'); if (copyIcon) { diff --git a/js/wordgun_ws.js b/js/wordgun_ws.js new file mode 100644 index 0000000..20c6bab --- /dev/null +++ b/js/wordgun_ws.js @@ -0,0 +1,182 @@ +// Minimal WebSocket guess channel for the Wordgun v2 API (WS /v2/send_guess). +// +// One game per socket: the socket is reopened whenever a new game token arrives. +// Every guess carries an `id` so its reply stays matchable even when guesses are +// pipelined or the server rejects the frame. The socket is stateless — the token +// carries the whole game — so a dropped connection is simply reopened. +// +// When the socket cannot be opened WORDGUN_WS_MAX_OPEN_FAILURES times in a row, +// this module reports itself unavailable and api.js silently falls back to the +// slower HTTP endpoint (POST /v1/guess), which v2 tokens also accept. The failure +// count survives across games on purpose: where WebSockets are blocked, retrying +// every round would stall the first guesses of every round on the open timeout. +// A single successful reply clears it. + +const WORDGUN_WS_URL = 'wss://api.wordgun.ru/v2/send_guess'; +const WORDGUN_WS_OPEN_TIMEOUT_MS = 5000; +const WORDGUN_WS_REQUEST_TIMEOUT_MS = 10000; +const WORDGUN_WS_MAX_OPEN_FAILURES = 3; + +const wordgun_socket = { + ws: null, + token: null, + open_promise: null, + pending: new Map(), + next_id: 0, + open_failures: 0 +}; + +// Point the socket at a new game. Closing the old one keeps the +// "one game per socket" contract of the API. +function wordgun_ws_set_game(token) { + if (wordgun_socket.token === token) return; + wordgun_ws_close(); + wordgun_socket.token = token; +} + +function wordgun_ws_close() { + const ws = wordgun_socket.ws; + wordgun_socket.ws = null; + wordgun_socket.open_promise = null; + wordgun_ws_reject_pending(new Error('Wordgun WebSocket закрыт')); + + if (ws) { + // Detach handlers first so our own close() does not re-enter the cleanup. + ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null; + try { ws.close(); } catch {} + } +} + +function wordgun_ws_reject_pending(error) { + for (const entry of wordgun_socket.pending.values()) { + clearTimeout(entry.timeout); + entry.reject(error); + } + wordgun_socket.pending.clear(); +} + +// True while the socket is worth trying. Once it has failed to open too often we +// stay on HTTP for the rest of the game instead of stalling every guess. +function wordgun_ws_available(token) { + return typeof WebSocket !== 'undefined' + && !!token + && wordgun_socket.open_failures < WORDGUN_WS_MAX_OPEN_FAILURES; +} + +function wordgun_ws_ensure_open() { + if (wordgun_socket.ws && wordgun_socket.ws.readyState === WebSocket.OPEN) { + return Promise.resolve(wordgun_socket.ws); + } + if (wordgun_socket.open_promise) { + return wordgun_socket.open_promise; + } + + const open_promise = new Promise((resolve, reject) => { + let ws; + try { + ws = new WebSocket(WORDGUN_WS_URL); + } catch (error) { + reject(error); + return; + } + + // Tracks whether this socket ever answered: a socket that dies without a + // single reply counts as a failed connection, one that worked does not. + let answered = false; + + const open_timeout = setTimeout(() => { + try { ws.close(); } catch {} + reject(new Error('Таймаут открытия Wordgun WebSocket')); + }, WORDGUN_WS_OPEN_TIMEOUT_MS); + + ws.onopen = () => { + clearTimeout(open_timeout); + wordgun_socket.ws = ws; + resolve(ws); + }; + + ws.onmessage = (event) => { + if (wordgun_ws_handle_message(event)) answered = true; + }; + + // onerror is always followed by onclose, which does the cleanup. + ws.onerror = () => {}; + + ws.onclose = () => { + clearTimeout(open_timeout); + if (wordgun_socket.ws === ws) wordgun_socket.ws = null; + if (wordgun_socket.open_promise === open_promise) wordgun_socket.open_promise = null; + if (!answered) wordgun_socket.open_failures++; + wordgun_ws_reject_pending(new Error('Wordgun WebSocket закрыт')); + // No-op once the socket has already opened. + reject(new Error('Wordgun WebSocket закрыт')); + }; + }); + + open_promise.catch(() => { + if (wordgun_socket.open_promise === open_promise) wordgun_socket.open_promise = null; + }); + + wordgun_socket.open_promise = open_promise; + return open_promise; +} + +// Returns true when the frame resolved a pending guess, i.e. the socket is alive. +function wordgun_ws_handle_message(event) { + let data; + try { + data = JSON.parse(event.data); + } catch { + console.warn('Wordgun WebSocket: не удалось разобрать кадр', event.data); + return false; + } + + const entry = wordgun_socket.pending.get(data?.id); + if (!entry) { + console.warn('Wordgun WebSocket: ответ без совпадающего id', data); + return false; + } + + wordgun_socket.pending.delete(data.id); + clearTimeout(entry.timeout); + wordgun_socket.open_failures = 0; + + if (data.error) { + // A server-side rejection (bad/expired token, malformed frame). Marked so + // the caller knows retrying over HTTP would fail the same way. + const error = new Error(`Wordgun WebSocket: ${data.error}`); + error.wordgun_rejected = true; + entry.reject(error); + return true; + } + + entry.resolve(data); + return true; +} + +// Send one guess and resolve with the raw v2 reply: { in_vocab, rank, is_live }. +async function wordgun_ws_guess(token, word) { + if (!token) throw new Error('Wordgun WebSocket: токен игры не задан'); + // Guard against a token change that never went through createGame. + if (wordgun_socket.token !== token) wordgun_ws_set_game(token); + + const ws = await wordgun_ws_ensure_open(); + const id = `g${++wordgun_socket.next_id}`; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + wordgun_socket.pending.delete(id); + reject(new Error('Таймаут ответа Wordgun WebSocket')); + }, WORDGUN_WS_REQUEST_TIMEOUT_MS); + + wordgun_socket.pending.set(id, { resolve, reject, timeout }); + + try { + ws.send(JSON.stringify({ token: token, word: word, id: id })); + } catch (error) { + clearTimeout(timeout); + wordgun_socket.pending.delete(id); + reject(error); + } + }); +} From 1555358f4d9809cd55a3f93a910f04e0bcd41fed Mon Sep 17 00:00:00 2001 From: Ars_Mond Date: Mon, 27 Jul 2026 16:27:55 +0500 Subject: [PATCH 2/6] feat: fix Wordgun model and drop the WebSocket guess channel --- index.html | 7 -- js/api.js | 38 ++-------- js/config.js | 2 +- js/settings.js | 43 ++++------- js/wordgun_ws.js | 182 ----------------------------------------------- 5 files changed, 20 insertions(+), 252 deletions(-) delete mode 100644 js/wordgun_ws.js diff --git a/index.html b/index.html index 01da6b1..b6005b3 100644 --- a/index.html +++ b/index.html @@ -111,12 +111,6 @@ - + +
Источник слов (бэкенд)
+ + +
@@ -111,12 +117,6 @@
-