Как стать автором
Обновить

Три подсказки для Wordle

Время на прочтение8 мин
Количество просмотров6.3K

Для того, чтобы "сломать" эту популярную в начале 22-го игру не нужно городить огород, достаточно подглядеть ответ в консоли разработчика.

Я люблю игры, но платонически, как произведения исксства, то есть я люблю как они сделаны, но не очень люблю играть. Однако такие игры как wordle способны меня захватить.

На хабре да и в интернете в общем есть много ботов, уоторые угадывают слово с трех попыток, но мне захотелось сделать чуть иначе: добавить возможность взвать подсказку. В целом метод их добычи очень похож на алгоритмы работы "решалок", но здесь я опишу своими словами от первого лица как, почему, и куда это вставлять.

Начнем с того, что это мой первый пост на хабре, я не являюсь программистом или математиком. Я работаю режиссером монтажа (если меня тут не забанят, я буду писать об этом) и являюсь энтузиастом програмирования, это помогает мне в работе и в отдыхе, люблю головоломки. Так что не чувствуйте себя стесненными дать какой-то совет, указать на ошибки или откровенную тупость. Так же не надейтесь на какой-то уровень знания компьютер сайенс выше 11-го класса районной школы.

Чтобы запускать скрипт, я буду использовать расширение для Chrome "User JavaScript and CSS", суперполезная штука для решения рутинных проблем.

1. Шаг первый

Для начала нам нужно извлечь список слов и поколдовать с буквами. Для этого нам нужен список слов. Прямо из консоли, в отличие от правильного ответа, его, увы, не получить. Поэтому будем использовать fetch:

let regex = /(?<=La=\[).+(?=\]\,Ta)/gm,
alphabet = {},
wordsList = [],
weights = {};

// Каждый день у игры новый хэш, его легко достать из объекта window,
// затем в тексте файла нужно найти переменную со списком слов
// и превратить ее из текста в массив
fetch(`main.${window.wordle.hash}.js`)
	.then(response => response.text())
	.then(data => wordsList = JSON.parse(`[${regex.exec(data)}]`))
	.then(_ => getWeights ());

function getWeights () {
  // Мы просто проходимся по каждой букве каждого слова
  // и добавляем к счетчику в объекте alphabet
	wordsList.forEach(w => {
	    w.split("").forEach(
	         l => {alphabet[l] = alphabet[l] ? alphabet[l]+1 : 1
	        })
	    });
  // Было бы неплохо его (объект alphabet) отсортировать
	var sortable = [];
	for (var letter in alphabet) {
	    sortable.push([letter, alphabet[letter]]);
	}
	sortable.sort(function(a, b) {
	    return a[1] - b[1];
	});
  // Заполняем "таблицу мер и весов"
	sortable.forEach((l, i) => {
	    weights[l[0]] = i
	});
}

2. Первая подсказка

Нужные данные мы получили, можем двигаться непосредственно к созданию подсказок!

Для выбора стартового слова народ обычно смотрит на частотность появления букв на той или иной позиции слов из списка. Таким образом лучшим словом может оказаться то, в котором одна и та же буква может повторяться (но скорее всего не окажется). Я же решил, что для первой подсказки лучше всего использовать как можно больше букв (ну то есть пять, максимум, край), поэтому я хочу найти слово в котором встречаются самые частые буквы не повторяясь. Скорее всего это будут слова SLATE, LATER или LASER, тут все зависит от списка слов, который, как я понимаю иногда меняется. Даже если не так, то это хорошее упражнение:

// Нам понадобятся само приложение, к которому можно обратиться
// через querySelector и переменные для подсказок
let app = document.querySelector("game-app"),
let firstBest = ["", 0],
secondBest = ["", 0],
thirdBest = ["", 0];

function getFirstGuess () {
  // Теперь считаем суммарный вес каждого слова
	wordsList.forEach(w => {
	    weight = 0;
    	// Причем для этого мы убираем повторяющиеся буквы,
    	// чтобы например слова с двумя «l» не были слишком тяжелыми -
    	// они нам не интересны
	    [...new Set(w.split(""))].forEach(l => {
	        weight += weights[l];
	    });
	    if (weight > firstBest[1]) {
	        firstBest = [w, weight]
	    }
	});
  
  // Выводим подсказку на табло
  print(firstBest[0])
}
Пробуем. 3/5 букв присутствуют в ответе. Неплохо!
Пробуем. 3/5 букв присутствуют в ответе. Неплохо!

У меня пока что всегда стартовое слово получается LATER. Не очень весело, но это лучший вариант за неимением других данных.

3. Спорный момент

Как поступить? Какую стратегию применить для второй подсказки?

С одной стороны нужно отталкиваться от результата первой: сколько букв угадали, какие на своих местах? Если мало - надо проверить еще часто встречающиеся буквы, чтобы они не пересекались с буквами из первой подсказки. Принцип тот же:

function getSecondGuess () {
  //  Это я объясню позже
  secondBest = ["", 0];
  // Здесь нам понадобятся результаты предыдущей попытки
	let evaulations = app.letterEvaluations;
  // Снова считаем вес
	wordsList.forEach(w => {
	    weight = 0;
	    [...new Set(w.split(""))].forEach(l => {
	        weight += weights[l];
	    });
    	// Но теперь с условием:
    	// в самом "тяжелом" слове не должно быть букв
    	// из первой подсказки и тех, которые как игра нам сказала,
    	// отсутствуют в правильном решении
	    unique = !firstBest[0]
        .split("").some(el => {return w.includes(el)}) 
    				&& [...new Set(w.split(""))].every(el =>
                                               evaulations[el] != "absent"
                                              );
	    if (weight > secondBest[1] && unique) {
	        secondBest = [w, weight]
	    }
	});
	print(secondBest[0])
}

Если же в первой попытке много букв попали в точку, это подсказка скорее всего не будет очень информативной, возможно это просто будут пять букв мимо.

Не густо. Однако теперь у меня есть целых семь букв, которых в правильном ответе точно не встретишь, что сокращает алфавит до 19-и символов. А ввести я могу еще 20!
Не густо. Однако теперь у меня есть целых семь букв, которых в правильном ответе точно не встретишь, что сокращает алфавит до 19-и символов. А ввести я могу еще 20!

Но я ведь не ставлю задачу угадать слово во чтобы то ни стало в два шага!? Нет. Задача — добавить подсказки, а отсутствие подходящих букв это тоже подсказка. Пока двинемся дальше.

4. Очень много условий

В качестве третье подсказки, чтобы она была полезна, нам нужно:

  1. Буквы, которые встали на свои места в предыдущих попытках, использовать в соответствующих позициях

  2. Все буквы, которые присутствуют в слове, но стоят на неправильном месте должны оказаться на других позициях

  3. Буквы, которых нет в слове, разумеется в третьей подсказке быть не должно

Звучит как план. Возможно третья попытка даже будет правильным ответом. Проверим?

function getThirdGuess () {
  // К этому вернемся позже
	thirdBest = ["", 0];
	evaulations = app.letterEvaluations;
  // Ищем буквы, присутствующие в ответе, на своих и на чужих местах
	let correct = [undefined, undefined, undefined, undefined, undefined],
	wrongPlace = {};
	app.boardState.forEach((w, i) => {
	    if (app.evaluations[i] != null) {
	        app.evaluations[i].forEach(
	            (e,j) => {
	                if (e == "correct") {
	                    correct[j] = w[j]
	                } else if (e == "present") {
	                    if (wrongPlace[w[j]]) {
	                        wrongPlace[w[j]].push(j)
	                    } else {
	                        wrongPlace[w[j]] = [j]
	                    }
	                }
	            })
	    }
	}) // Ужас, но что поделать
  // Тут я соберу все присутствующие и правильные буквы
  // Начинается путаница с названиями переменных, прошу простить
  let present = Object.entries(evaulations)
  .filter(e => 
          e[1] == "present" || e[1] == "correct"
         ).map(e => e[0]);
	
  // Снова считаем массы
	wordsList.forEach(w => {
	    weight = 0;
	    let letters = [...new Set(w.split(""))];
	    letters.forEach(l => {
	        weight += weights[l];
	    });
    	// Мама, не выгоняй меня из дома, я проверяю:
    	// 1. В слове нет отсутствующих в ответе букв
    	// 2. Каждая присутствующая буква есть в слове
    	// 3. Каждая правильная буква на правильной позиции
    	// 4. Все угаданные на не правильном месте буквы стоят
    	// в других местах
	    let passing = letters
      	.every(el => evaulations[el] != "absent") 
      && present.every(el => w.includes(el)) 
      && w.split("")
      	.every((el, i) => correct[i] == el ||  correct[i] == undefined) 
      && w.split("")
      	.every((el, i) => ![...wrongPlace[el]||[]].includes(i));

	    if (weight >= thirdBest[1] && passing) {
	        thirdBest = [w, weight]
	    }
	});
	print(thirdBest[0])
}

Ладно, у меня такая риторика, будто мне стыдно за этот код. Но нет! Ну разве что может чуть-чуть.

Мда. А разговоров-то было...
Мда. А разговоров-то было...

Как-то не похоже на правильный ответ. Но ведь наша цель — подсказка! Это она и есть. В багажнике у этой подсказки было 26 вариантов, она выбрала тот, где буквы встречаются чаще всего, и дала: пожалуйста, третья буква точно A, четвертая и пятая возможно какая-то из букв R и E. А может первая и вторая просто поменяны местами? Тогда это слово может быть ERASE. Но буквы S в правильном ответе нет. Окей, слова FRAER в списке нет, но зато есть куча слов, заканчивающихся на ARE: FLARE например. Буква L тоже не подходит, но давайте я попробую, чтобы ускорить процесс:

Я начинаю догадываться...
Я начинаю догадываться...

Нет сомнений, мы имеем дело со словом FRAME. Есть еще FLAME и FLAKE, но там и там вторая буква L.

Три подсказки и два самостоятельных хода. Задача решена

5. Столько всего интересного

Столько всего интересного я пропущу, если не захочу копнуть глубже, куда-то в сторону науки о данных. Ничего в этом не понимаю, но очень хочется.

Я заметил, что вторая подсказка выглядит как-то глупо, но так ли это? Чтобы проверить, я добавил возможность вызывать подсказки по нескольку раз. Для этого я просто очищаю предложенное лучшее слово и снова запускаю алгоритм.

someBest = ["", 0];
Жмем на подсказки, пока не получим правильный ответ.
Жмем на подсказки, пока не получим правильный ответ.

Давайте попробуем после третьей подсказки вызвать ее еще раз. Получим GRAPE. Классное слово, почему я сам о нем не подумал? Вариантов остается тоже немного, и вызвав третью подсказку третий раз, мы получаем правильный ответ. Ура!

А что если вызвать вторую подсказку два раза? Снова ничего примечательного: ну окей, ну одна буковка. Но прикол в том, что мы использовали уже 15 букв, столько же симфолов осталось в алфавите, четыре из которых есть в слове. Больше вторая подсказка нам ничего не даст, просто нет слов, отвечающих критериям. В этом случае у алгоритма третьей подсказки тоже остается очень мало вариантов для выбора.

А если мы опустим вторую подсказку и сразу обратимся к третьей? Ведь ее алгоритм возможно более эффективен, в случае, когда мы знаем так много букв? Увы, это не так. Третий алгоритм не сокращает алфавит так сильно, он перебирает всего по две буквы, когда второй делает пять за раз.

Выводы мне, лично сделать сложно, поэтому я обращусь к сообществу. Если вы можете посоветовать ресурсы, где можно весело, играючи и на практике поизучать алгоритмы подбора, не стесняйте себя в комментариях, буду крайне благодарен.

6. О красоте

Давайте добавим подсказочки в интерфейс.

// Сперва нужно создать функцию print,
// чтобы выводить рехультат подсказок на табло
function print(w, enter=false) {
  // Делается это с помощью KeyboardEvent'ов:
  // именно так работает экранная клавиатура в игре
	w.split("").forEach(l => 
		window.dispatchEvent(
			new KeyboardEvent('keydown', {'key': l})
			));
	if (enter) {
		window.dispatchEvent(
			new KeyboardEvent('keydown', {'key': 'Enter'})
			);
	}
}

// Еще одна помогайка, название говорит само за себя
function applyStyle(el, style){
	Object.entries(style).forEach(p => {
		el.style[p[0]] = p[1];
	})
}

// Создадим контейнер для кнопок,
// а так же найдем куда его поместить (для этого придется
// залезть в сумеречную зону)
let suggestions = document.createElement("div"),
header = document.querySelector("game-app").shadowRoot
.querySelector("game-theme-manager > div > header");

// Накинем стиль на контейнер и кнопки
// Я делаю это здесь, а не в поле CSS потому что он находится
// в Shadow Root и CSS до него не доберется
applyStyle(suggestions, {
	display: "grid",
	gridTemplateColumns: "repeat(3, 1fr)",
	gridGap: "5px",
	justifyItems: "center",
	padding: "10px",
	margin: "auto",
	boxSizing: "border-box",
	maxWidth: "350px",
	width: "100%",
	fontSize: "1.5rem",
	lineHeight: "2rem",
	fontWeight: "bold",
	verticalAlign: "middle",
	color: "var(--tile-text-color)",
});

let buttonStyle = {
	textAlign: "center",
	width: "100%",
	background: "#a52a2a"
};

// Чтобы не повторяться соорудил вот это
let buttons = [
	["First", getFirstGuess],
	["Second", getSecondGuess],
	["Third", getThirdGuess]
].forEach(b => {
	let el = document.createElement("div");
	applyStyle(el, buttonStyle);
	el.innerText = b[0];
	el.onclick = b[1];
	suggestions.appendChild(el);
});

// Добавили кнопки в интерфейс
header.after(suggestions);
Теперь у нас есть кнопки. Красные. Большие.
Теперь у нас есть кнопки. Красные. Большие.

На этом, пожалуй все. Получилось объемнее, чем я ожидал, пока писал уложил в голове всю проделанную работу. Кстати о работке: на написание скрипта я потратил около двух-трех часов, столько же на написание статьи. То есть не очень уж трудозатратно это все. Если этот текст поддолкнул вас в каким-нибудь действиям, например поизучать предмет, я буду очень рад увидеть ваши заметки в комментариях или ответном тексте.

Скрипт целиком пожно посмотреть тут

UPD:

Игра окончательно переехала на сайт nytimes, и вмсете с этим кое-что изменилось в скрипте (имена переменных и путь для вставки кнопок). Поскольку никакой существенной концептуальной разницы это не вносит, я не буду менять текст статьи, но обновлю gist.

Теги:
Хабы:
Рейтинг0
Комментарии7

Публикации

Истории

Работа

Ближайшие события

Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург