Skip to content
12 changes: 11 additions & 1 deletion css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {

Expand Down
16 changes: 11 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -87,18 +87,24 @@
<div class="title">Перезапуск игры после победы (секунды)</div>
<input type="number" id="restart-time" name="restart_time" placeholder="20" min="0">
</div>
</div>
<div class="ui-col">
<div class="setting">
<div class="title">Источник слов (бэкенд)</div>
<select id="game-backend" name="game_backend">
<option value="kontekstno">контекстно.рф</option>
<option value="wordgun">wordgun.ru</option>
</select>
<div id="backend-warning" class="backend-warning" style="display: none;">
⚠️ wordgun.ru может работать некорректно с российских IP-адресов. Если игра не запускается, используйте VPN или выберите контекстно.рф.
</div>
<div id="backend-warning" class="backend-warning" style="display: none;"> ⚠️ wordgun.ru
может работать некорректно с российских IP-адресов. Если игра не запускается,
используйте VPN или выберите контекстно.рф. </div>
</div>
<div class="setting wordgun-setting" style="display: none;">
<div class="title">Сложность</div>
<select id="wordgun-difficulty" name="wordgun_difficulty">
<option value=""></option>
</select>
</div>
</div>
<div class="ui-col">
<div class="setting checkbox-container">
<label class="title" for="win-avatar-enable">Показывать аватарку победителя?</label>
<div>
Expand Down
69 changes: 62 additions & 7 deletions js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
);
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}

Expand Down Expand Up @@ -225,26 +226,52 @@ 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',
supportsTips: true,
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 }
});
Expand All @@ -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;
}
Expand Down Expand Up @@ -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));
Expand All @@ -328,10 +377,16 @@ function show_fullscreen_error(message) {
<div class="error-content">
<div class="error-icon">⚠️</div>
<div class="error-message">${message}</div>
<button type="button" class="error-close-btn">Закрыть</button>
</div>
</div>
`;
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) {
Expand Down
2 changes: 2 additions & 0 deletions js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// Состояние игры
Expand Down
1 change: 1 addition & 0 deletions js/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ async function app() {

} else {
document.getElementById('settings').style.display = 'block';
if (game_backend === 'wordgun') loadWordgunOptions();
}

} catch (error) {
Expand Down
105 changes: 98 additions & 7 deletions js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

if (webhook_url) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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();
});
Expand Down
Loading