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) {
`;
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();
});