diff --git a/src/lib/dict.ts b/src/lib/dict.ts new file mode 100644 index 0000000..8e08a95 --- /dev/null +++ b/src/lib/dict.ts @@ -0,0 +1,29 @@ +import { remove_accent } from "./utils.ts"; + +export class Dict { + words; + letters; + length; + + constructor(words: Set, length: number) { + this.words = words; + this.length = length; + this.letters = new Set([...words.values()].map((w) => w.split("")).flat()); + } + + static async from_file(path: string, length: number) { + const words = new Set(); + const content = await Deno.readTextFile(path); + for (const word of content.split("\n")) { + const word_ = word.trim().toLowerCase(); + if (word_.length !== length) continue; + for (const forbidden of [" ", "-", "."]) if (word_.includes(forbidden)) continue; + words.add(remove_accent(word_)); + } + return new Dict(words, length); + } + + [Symbol.for("Deno.customInspect")]() { + return `Dict { ${this.words.size} words }`; + } +} diff --git a/src/lib/game/game.ts b/src/lib/game/game.ts new file mode 100644 index 0000000..be79b9d --- /dev/null +++ b/src/lib/game/game.ts @@ -0,0 +1,3 @@ +export type Info = { kind: "abscent" } | { kind: "somewhere" } | { kind: "there" }; + +export type GuessResult = { kind: "success" } | { kind: "failure"; informations: Info[] }; diff --git a/src/lib/game/simulator.ts b/src/lib/game/simulator.ts new file mode 100644 index 0000000..b71a82d --- /dev/null +++ b/src/lib/game/simulator.ts @@ -0,0 +1,43 @@ +import { Dict } from "../dict.ts"; +import { enumerate, range, zip } from "../utils.ts"; +import { GuessResult, Info } from "./game.ts"; + +export class Simulator { + word; + + constructor(word: string) { + this.word = word; + } + + static from_dict_rand(dict: Dict) { + const index = Math.floor(Math.random() * dict.words.size); + const word = [...dict.words.values()][index]; + return new Simulator(word); + } + + try_guess(guess: string): GuessResult { + if (guess === this.word) return { kind: "success" }; + const rest_actual = [...this.word].map((letter) => letter as (string | null)); + const rest_guess = [...guess].map((letter) => letter as (string | null)); + const info = [...range(0, this.word.length)].map(() => null) as (Info | null)[]; + + for (const [index, [guessed, actual]] of enumerate(zip(rest_guess, rest_actual))) { + if (guessed !== actual) continue; + rest_actual[index] = null; + rest_guess[index] = null; + info[index] = { kind: "there" }; + } + + for (const [index, guessed] of enumerate(rest_guess)) { + if (guessed === null) continue; + if (!rest_actual.includes(guessed)) continue; + info[index] = { kind: "somewhere" }; + rest_guess[index] = null; + // note : removes only one. + rest_actual[rest_actual.indexOf(guessed)] = null; + } + + const informations = info.map((i) => i != undefined ? i : ({ kind: "abscent" }) as Info); + return { kind: "failure", informations }; + } +} diff --git a/src/lib/guesser.ts b/src/lib/guesser.ts new file mode 100644 index 0000000..6ba23d9 --- /dev/null +++ b/src/lib/guesser.ts @@ -0,0 +1,112 @@ +import { assertExists } from "https://deno.land/std@0.224.0/assert/assert_exists.ts"; + +import { enumerate, range } from "./utils.ts"; +import { Dict } from "./dict.ts"; +import { GuessResult } from "./game/game.ts"; + +type Knowledge = { + letter: string; + at: Set; + exists: "unknown" | boolean; + not_at: Set; +}; + +export class Gueser { + length; + dict; + informations; + + constructor(dict: Dict) { + this.length = dict.length; + this.dict = dict; + this.informations = new Map(); + for (const letter of dict.letters.values()) { + this.informations.set(letter, { letter, at: new Set(), not_at: new Set(), exists: "unknown" }); + } + } + + async next_guess(operation: (guess: string, known: string) => GuessResult | Promise) { + const res = this.expected_result(); + if (res.completed) return operation(res.result, res.result); + + const words = [...this.dict.words.values()]; + const scored = words.map((word) => [this.info_score_for(word), word] as const); + const best_score = scored.reduce((acc, [next]) => Math.max(acc, next), 0); + const bests = scored.filter(([s]) => s === best_score).map(([_, word]) => word); + const guess = pick_random_common_word(bests); + + const result = await operation(guess, res.result); + if (result.kind === "success") return guess; + for (const [index, info] of enumerate(result.informations)) { + const letter = guess[index]; + if (info.kind === "there") this.learn_letter_at(letter, index); + if (info.kind === "abscent") this.learn_does_not_exist(letter); + if (info.kind === "somewhere") { + this.learn_does_exist(letter); + this.learn_letter_not_at(letter, index); + } + } + } + + learn_does_not_exist(letter: string) { + const letter_info = this.informations.get(letter); + assertExists(letter_info); + letter_info.exists = false; + } + + learn_does_exist(letter: string) { + const letter_info = this.informations.get(letter); + assertExists(letter_info); + letter_info.exists = true; + const total_known = [...this.informations.values()].filter((i) => i.exists === true).length; + if (total_known < this.length) return; + for (const info of this.informations.values()) if (info.exists === "unknown") info.exists = false; + } + + learn_letter_at(letter: string, at: number) { + const letter_info = this.informations.get(letter); + assertExists(letter_info); + letter_info.at.add(at); + for (const l of this.informations.keys()) { + if (letter === l) continue; + this.learn_letter_not_at(l, at); + } + } + + learn_letter_not_at(letter: string, at: number) { + const letter_info = this.informations.get(letter); + assertExists(letter_info); + letter_info.not_at.add(at); + } + + info_score_for(word: string) { + let total = 0; + for (const [index, letter] of enumerate(word)) { + const information = this.informations.get(letter); + assertExists(information); + if (information.exists === false) continue; + if (information.at.has(index)) continue; + if (information.not_at.has(index)) continue; + if (information.exists === "unknown") total += this.length; + if (information.exists === true) total += this.length * this.length; + } + const different_letters = new Set([...word]); + total += different_letters.size; + return total; + } + + expected_result() { + const known_arr = [...range(0, this.length)].map(() => null as string | null); + for (const [letter, info] of this.informations.entries()) { + for (const pos of info.at.values()) known_arr[pos] = letter; + } + const result = known_arr.map((l) => l === null ? "." : l).join(""); + const completed = !result.includes("."); + return { completed, result }; + } +} + +function pick_random_common_word(bests: string[]) { + const index = Math.floor(Math.random() * bests.length); + return bests[index]; +} diff --git a/src/lib/lib.ts b/src/lib/lib.ts index 2b7b6f8..e5f3473 100644 --- a/src/lib/lib.ts +++ b/src/lib/lib.ts @@ -1,226 +1,3 @@ -import { assertExists } from "https://deno.land/std@0.224.0/assert/assert_exists.ts"; - -export type GuessResult = { kind: "success" } | { kind: "failure"; informations: Info[] }; - -export class Dict { - words; - letters; - length; - - constructor(words: Set, length: number) { - this.words = words; - this.length = length; - this.letters = new Set([...words.values()].map((w) => w.split("")).flat()); - } - - static async from_file(path: string, length: number) { - const words = new Set(); - const content = await Deno.readTextFile(path); - for (const word of content.split("\n")) { - const word_ = word.trim().toLowerCase(); - if (word_.length !== length) continue; - for (const forbidden of [" ", "-", "."]) if (word_.includes(forbidden)) continue; - words.add(remove_accent(word_)); - } - return new Dict(words, length); - } - - [Symbol.for("Deno.customInspect")]() { - return `Dict { ${this.words.size} words }`; - } -} - -type Knowledge = { - letter: string; - at: Set; - exists: "unknown" | boolean; - not_at: Set; -}; - -export class Gueser { - length; - dict; - informations; - - constructor(dict: Dict) { - this.length = dict.length; - this.dict = dict; - this.informations = new Map(); - for (const letter of dict.letters.values()) { - this.informations.set(letter, { letter, at: new Set(), not_at: new Set(), exists: "unknown" }); - } - } - - async next_guess(operation: (guess: string, known: string) => GuessResult | Promise) { - const res = this.expected_result(); - if (res.completed) return operation(res.result, res.result); - - const words = [...this.dict.words.values()]; - const scored = words.map((word) => [this.info_score_for(word), word] as const); - const best_score = scored.reduce((acc, [next]) => Math.max(acc, next), 0); - const bests = scored.filter(([s]) => s === best_score).map(([_, word]) => word); - const guess = pick_random_common_word(bests); - - const result = await operation(guess, res.result); - if (result.kind === "success") return guess; - for (const [index, info] of enumerate(result.informations)) { - const letter = guess[index]; - if (info.kind === "there") this.learn_letter_at(letter, index); - if (info.kind === "abscent") this.learn_does_not_exist(letter); - if (info.kind === "somewhere") { - this.learn_does_exist(letter); - this.learn_letter_not_at(letter, index); - } - } - } - - learn_does_not_exist(letter: string) { - const letter_info = this.informations.get(letter); - assertExists(letter_info); - letter_info.exists = false; - } - - learn_does_exist(letter: string) { - const letter_info = this.informations.get(letter); - assertExists(letter_info); - letter_info.exists = true; - const total_known = [...this.informations.values()].filter((i) => i.exists === true).length; - if (total_known < this.length) return; - for (const info of this.informations.values()) if (info.exists === "unknown") info.exists = false; - } - - learn_letter_at(letter: string, at: number) { - const letter_info = this.informations.get(letter); - assertExists(letter_info); - letter_info.at.add(at); - for (const l of this.informations.keys()) { - if (letter === l) continue; - this.learn_letter_not_at(l, at); - } - } - - learn_letter_not_at(letter: string, at: number) { - const letter_info = this.informations.get(letter); - assertExists(letter_info); - letter_info.not_at.add(at); - } - - info_score_for(word: string) { - let total = 0; - for (const [index, letter] of enumerate(word)) { - const information = this.informations.get(letter); - assertExists(information); - if (information.exists === false) continue; - if (information.at.has(index)) continue; - if (information.not_at.has(index)) continue; - if (information.exists === "unknown") total += this.length; - if (information.exists === true) total += this.length * this.length; - } - const different_letters = new Set([...word]); - total += different_letters.size; - return total; - } - - expected_result() { - const known_arr = [...range(0, this.length)].map(() => null as string | null); - for (const [letter, info] of this.informations.entries()) { - for (const pos of info.at.values()) known_arr[pos] = letter; - } - const result = known_arr.map((l) => l === null ? "." : l).join(""); - const completed = !result.includes("."); - return { completed, result }; - } -} - -type Info = { kind: "abscent" } | { kind: "somewhere" } | { kind: "there" }; - -function remove_accent(text: string) { - const accents = [ - ["à", "a"], - ["â", "a"], - ["ä", "a"], - ["ç", "c"], - ["é", "e"], - ["è", "e"], - ["ê", "e"], - ["ë", "e"], - ["î", "i"], - ["ï", "i"], - ["ô", "o"], - ["ö", "o"], - ["û", "u"], - ]; - let result = text; - for (const [accent, alternative] of accents) result = result.replaceAll(accent, alternative); - return result; -} - -function* enumerate(iterator: Iterable) { - let index = 0; - for (const item of iterator) yield [index++, item] as const; -} - -function* zip(iterable_a: Iterable, iterable_b: Iterable) { - const iter_a = iterable_a[Symbol.iterator](); - const iter_b = iterable_b[Symbol.iterator](); - while (true) { - const next_a = iter_a.next().value; - const next_b = iter_b.next().value; - if (next_a === undefined) return; - if (next_b === undefined) return; - yield [next_a as A, next_b as B] as const; - } -} - -export class Game { - word; - - constructor(word: string) { - this.word = word; - } - - static from_dict_rand(dict: Dict) { - const index = Math.floor(Math.random() * dict.words.size); - const word = [...dict.words.values()][index]; - return new Game(word); - } - - try_guess(guess: string): GuessResult { - if (guess === this.word) return { kind: "success" }; - const rest_actual = [...this.word].map((letter) => letter as (string | null)); - const rest_guess = [...guess].map((letter) => letter as (string | null)); - const info = [...range(0, this.word.length)].map(() => null) as (Info | null)[]; - - for (const [index, [guessed, actual]] of enumerate(zip(rest_guess, rest_actual))) { - if (guessed !== actual) continue; - rest_actual[index] = null; - rest_guess[index] = null; - info[index] = { kind: "there" }; - } - - for (const [index, guessed] of enumerate(rest_guess)) { - if (guessed === null) continue; - if (!rest_actual.includes(guessed)) continue; - info[index] = { kind: "somewhere" }; - rest_guess[index] = null; - // note : removes only one. - rest_actual[rest_actual.indexOf(guessed)] = null; - } - - const informations = info.map((i) => i != undefined ? i : ({ kind: "abscent" }) as Info); - return { kind: "failure", informations }; - } -} - -function* range(from: number, to: number) { - while (from < to) yield from++; -} - -export async function wait(ms: number) { - await new Promise((resolver) => setTimeout(resolver, ms)); -} - -function pick_random_common_word(bests: string[]) { - const index = Math.floor(Math.random() * bests.length); - return bests[index]; -} +export { Dict } from "./dict.ts"; +export { Gueser } from "./guesser.ts"; +export { Simulator } from "./game/simulator.ts"; diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..cf2ebfd --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,45 @@ +export function* enumerate(iterator: Iterable) { + let index = 0; + for (const item of iterator) yield [index++, item] as const; +} + +export function* zip(iterable_a: Iterable, iterable_b: Iterable) { + const iter_a = iterable_a[Symbol.iterator](); + const iter_b = iterable_b[Symbol.iterator](); + while (true) { + const next_a = iter_a.next().value; + const next_b = iter_b.next().value; + if (next_a === undefined) return; + if (next_b === undefined) return; + yield [next_a as A, next_b as B] as const; + } +} + +export function* range(from: number, to: number) { + while (from < to) yield from++; +} + +export async function wait(ms: number) { + await new Promise((resolver) => setTimeout(resolver, ms)); +} + +export function remove_accent(text: string) { + const accents = [ + ["à", "a"], + ["â", "a"], + ["ä", "a"], + ["ç", "c"], + ["é", "e"], + ["è", "e"], + ["ê", "e"], + ["ë", "e"], + ["î", "i"], + ["ï", "i"], + ["ô", "o"], + ["ö", "o"], + ["û", "u"], + ]; + let result = text; + for (const [accent, alternative] of accents) result = result.replaceAll(accent, alternative); + return result; +} diff --git a/src/main.ts b/src/main.ts index 2784005..8990a82 100755 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,15 @@ #!/usr/bin/env -S deno run --allow-read -import { Dict, Game, Gueser, GuessResult, wait } from "./lib/lib.ts"; +import { GuessResult } from "./lib/game/game.ts"; +import { Dict, Gueser, Simulator } from "./lib/lib.ts"; +import { wait } from "./lib/utils.ts"; async function main() { const length = 6; const dict = await Dict.from_file("./data/liste_francais.txt", length); console.log(dict); const guesser = new Gueser(dict); - const game = Game.from_dict_rand(dict); + const game = Simulator.from_dict_rand(dict); console.log("Target is", game.word); let index = 0; while (true) {