diff --git a/css/style.css b/css/style.css index 9369673..842ed86 100644 --- a/css/style.css +++ b/css/style.css @@ -376,7 +376,7 @@ body.has-custom-bg #info { align-items: center; justify-content: center; text-align: center; - font-size: 2rem; + font-size: 1.5rem; padding: 20px; z-index: 9999; animation: fadeInOpacity 0.5s ease-in-out; @@ -391,6 +391,16 @@ body.has-custom-bg #info { margin-bottom: 20px; } +.error-overlay .error-close-btn { + margin-top: 24px; + padding: 10px 24px; + border: 0; + border-radius: 8px; + font: inherit; + font-size: 1rem; + cursor: pointer; +} + /* временный фикс для обс и Квантума */ @media all and (max-width: 800px) { diff --git a/index.html b/index.html index 7509125..73c139b 100644 --- a/index.html +++ b/index.html @@ -87,18 +87,24 @@
Перезапуск игры после победы (секунды)
+ +
Источник слов (бэкенд)
- + +
+ -
-
diff --git a/js/api.js b/js/api.js index d17e931..687c856 100644 --- a/js/api.js +++ b/js/api.js @@ -61,7 +61,7 @@ async function kontekstno_query({ if (errorText.length > 200) { errorText = errorText.substring(0, 200) + '...'; } - } catch {} + } catch { } throw new Error( `HTTP ${response.status} ${response.statusText} ${errorText}` ); @@ -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(); @@ -141,10 +141,11 @@ async function wordgun_request(path, { method = 'GET', body = null } = {}) { if (!response.ok) { let errorBody = null; - try { errorBody = await response.json(); } catch {} + try { errorBody = await response.json(); } catch { } const error = new Error(`Wordgun HTTP ${response.status}: ${errorBody?.error || response.statusText}`); error.status = response.status; error.code = errorBody?.code; + error.apiMessage = errorBody?.error; throw error; } @@ -225,9 +226,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. + // The game is created on v2 to pick a model and difficulty; guesses go to the + // v1 endpoint, which accepts v2 tokens unchanged. wordgun: { id: 'wordgun', label: 'wordgun.ru', @@ -235,16 +238,40 @@ 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; + + let data; + + try { + data = await wordgun_request('/v2/create_game', { method: 'POST', body }); + } catch (error) { + const isUnknownDifficulty = error.status === 400 + && typeof error.apiMessage === 'string' + && error.apiMessage.startsWith('unknown difficulty'); + + if (body.difficulty && isUnknownDifficulty) { + error.userMessage = 'Выбранная сложность Wordgun больше не поддерживается. ' + + 'Откройте настройки и выберите новую сложность. ' + + 'Если используете OBS, замените ссылку браузерного источника.'; + } + + throw error; + } + if (!data?.token) { throw new Error('Wordgun API не вернул токен игры'); } + // 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', { + const result = await wordgun_request('/v1/guess', { method: 'POST', body: { token: gameId, word: word } }); @@ -259,13 +286,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; } @@ -308,6 +350,13 @@ async function generate_secret_word() { return game.gameId; } catch (e) { console.warn(`Не удалось создать игру (${backend.id}). Попытка ${retry_count + 1}/${max_retries}:`, e); + + // Ошибки настроек не исправятся повторным запросом. + if (e.userMessage) { + show_fullscreen_error(e.userMessage); + throw e; + } + retry_count++; // Небольшая пауза перед повтором при сетевой ошибке await new Promise(resolve => setTimeout(resolve, 1000)); @@ -328,10 +377,16 @@ function show_fullscreen_error(message) {
⚠️
${message}
+
`; document.body.insertAdjacentHTML('beforeend', error_html); + + const errorOverlay = document.querySelector('.error-overlay'); + errorOverlay?.querySelector('.error-close-btn')?.addEventListener('click', () => { + errorOverlay.remove(); + }); } async function getTwitchUserData(username) { diff --git a/js/config.js b/js/config.js index 5a8c55b..75aadd0 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' +const wordgun_model = 'ru-context-v1'; // wordgun v2 model; not user-configurable +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..703bc3f 100644 --- a/js/settings.js +++ b/js/settings.js @@ -6,7 +6,15 @@ 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 wordgunDifficultyInput = document.getElementById('wordgun-difficulty'); +const wordgunSettingBlocks = document.querySelectorAll('.wordgun-setting'); +const WORDGUN_DIFFICULTY_LABELS = { + medium: 'Обычная', + hard: 'Тяжелая', + hell: 'Ад' +}; let validationTimeout; +let wordgunOptionsLoaded = false; function parseBooleanSetting(value) { return String(value).toLowerCase() === 'true' || String(value) === '1'; @@ -16,6 +24,10 @@ function getSettingValue(urlParams, paramName, storageName) { return urlParams.has(paramName) ? urlParams.get(paramName) : localStorage.getItem(storageName); } +function getWordgunDifficultyLabel(value) { + return WORDGUN_DIFFICULTY_LABELS[value] || value; +} + function generateObsLink() { if (!channelInput || !channelInput.value.trim()) { if (obsLinkInput) { @@ -46,6 +58,12 @@ function generateObsLink() { if (gameBackendInput) { params.set('backend', gameBackendInput.value); + + // wordgun v2 difficulty: only meaningful for that backend, and an empty + // value already means "the whole vocabulary". + if (gameBackendInput.value === 'wordgun' && wordgunDifficultyInput && wordgunDifficultyInput.value) { + params.set('wg_difficulty', wordgunDifficultyInput.value); + } } if (webhook_url) { @@ -161,7 +179,18 @@ function loadSettings() { game_backend = storedBackend; } if (gameBackendInput) gameBackendInput.value = game_backend; - updateBackendWarning(); + + // если ранее в obs был сохранен wg_difficulty, а затем ссылка была обновлена так, что этот параметр отсутствует, то ставим дефолтное значение, а не используем сохраненное + let storedWordgunDifficulty = localStorage.getItem('wordgun_difficulty') || ''; + if (urlParams.get('backend') === 'wordgun') { + storedWordgunDifficulty = urlParams.get('wg_difficulty') || ''; + } + wordgun_difficulty = storedWordgunDifficulty.trim(); + + // Show the stored value right away; the real option list arrives from GET /v2/list_model only once the settings panel is actually opened. + ensureSelectOption(wordgunDifficultyInput, wordgun_difficulty); + + updateBackendSettings(); // Генерируем ссылку OBS при загрузке страницы generateObsLink(); @@ -192,6 +221,11 @@ if (saveBtn) { localStorage.setItem('game_backend', game_backend); } + if (wordgunDifficultyInput) { + wordgun_difficulty = wordgunDifficultyInput.value; + localStorage.setItem('wordgun_difficulty', wordgun_difficulty); + } + // Генерируем ссылку для OBS generateObsLink(); @@ -275,7 +309,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,16 +329,68 @@ 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(getWordgunDifficultyLabel(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(getWordgunDifficultyLabel(value), value))); + if (current && !values.includes(current)) { + select.add(new Option(`${getWordgunDifficultyLabel(current)} (недоступно)`, current)); + } + select.value = current || ''; +} + +// Pull the 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 || !wordgunDifficultyInput) return; + + try { + const info = await wordgun_list_models(); + wordgunOptionsLoaded = true; + fillSelectOptions(wordgunDifficultyInput, info.difficulties, wordgun_difficulty, 'Без ограничения'); + } catch (error) { + // A failed lookup must not wipe the saved setting, so keep what we have. + console.warn('Не удалось загрузить список сложностей wordgun:', error); + ensureSelectOption(wordgunDifficultyInput, wordgun_difficulty); + } } if (gameBackendInput) { gameBackendInput.addEventListener("change", () => { - updateBackendWarning(); + updateBackendSettings(); + if (gameBackendInput.value === 'wordgun') loadWordgunOptions(); + generateObsLink(); + checkFormsValidity(); + }); +} + +if (wordgunDifficultyInput) { + wordgunDifficultyInput.addEventListener("change", () => { generateObsLink(); checkFormsValidity(); });