split into modules
This commit is contained in:
parent
de66dec939
commit
f5e8aa12d9
7 changed files with 239 additions and 228 deletions
29
src/lib/dict.ts
Normal file
29
src/lib/dict.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { remove_accent } from "./utils.ts";
|
||||||
|
|
||||||
|
export class Dict {
|
||||||
|
words;
|
||||||
|
letters;
|
||||||
|
length;
|
||||||
|
|
||||||
|
constructor(words: Set<string>, 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<string>();
|
||||||
|
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 }`;
|
||||||
|
}
|
||||||
|
}
|
3
src/lib/game/game.ts
Normal file
3
src/lib/game/game.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export type Info = { kind: "abscent" } | { kind: "somewhere" } | { kind: "there" };
|
||||||
|
|
||||||
|
export type GuessResult = { kind: "success" } | { kind: "failure"; informations: Info[] };
|
43
src/lib/game/simulator.ts
Normal file
43
src/lib/game/simulator.ts
Normal file
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
112
src/lib/guesser.ts
Normal file
112
src/lib/guesser.ts
Normal file
|
@ -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<number>;
|
||||||
|
exists: "unknown" | boolean;
|
||||||
|
not_at: Set<number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Gueser {
|
||||||
|
length;
|
||||||
|
dict;
|
||||||
|
informations;
|
||||||
|
|
||||||
|
constructor(dict: Dict) {
|
||||||
|
this.length = dict.length;
|
||||||
|
this.dict = dict;
|
||||||
|
this.informations = new Map<string, Knowledge>();
|
||||||
|
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<GuessResult>) {
|
||||||
|
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];
|
||||||
|
}
|
229
src/lib/lib.ts
229
src/lib/lib.ts
|
@ -1,226 +1,3 @@
|
||||||
import { assertExists } from "https://deno.land/std@0.224.0/assert/assert_exists.ts";
|
export { Dict } from "./dict.ts";
|
||||||
|
export { Gueser } from "./guesser.ts";
|
||||||
export type GuessResult = { kind: "success" } | { kind: "failure"; informations: Info[] };
|
export { Simulator } from "./game/simulator.ts";
|
||||||
|
|
||||||
export class Dict {
|
|
||||||
words;
|
|
||||||
letters;
|
|
||||||
length;
|
|
||||||
|
|
||||||
constructor(words: Set<string>, 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<string>();
|
|
||||||
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<number>;
|
|
||||||
exists: "unknown" | boolean;
|
|
||||||
not_at: Set<number>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Gueser {
|
|
||||||
length;
|
|
||||||
dict;
|
|
||||||
informations;
|
|
||||||
|
|
||||||
constructor(dict: Dict) {
|
|
||||||
this.length = dict.length;
|
|
||||||
this.dict = dict;
|
|
||||||
this.informations = new Map<string, Knowledge>();
|
|
||||||
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<GuessResult>) {
|
|
||||||
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<T>(iterator: Iterable<T>) {
|
|
||||||
let index = 0;
|
|
||||||
for (const item of iterator) yield [index++, item] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function* zip<A, B>(iterable_a: Iterable<A>, iterable_b: Iterable<B>) {
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
45
src/lib/utils.ts
Normal file
45
src/lib/utils.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
export function* enumerate<T>(iterator: Iterable<T>) {
|
||||||
|
let index = 0;
|
||||||
|
for (const item of iterator) yield [index++, item] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* zip<A, B>(iterable_a: Iterable<A>, iterable_b: Iterable<B>) {
|
||||||
|
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;
|
||||||
|
}
|
|
@ -1,13 +1,15 @@
|
||||||
#!/usr/bin/env -S deno run --allow-read
|
#!/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() {
|
async function main() {
|
||||||
const length = 6;
|
const length = 6;
|
||||||
const dict = await Dict.from_file("./data/liste_francais.txt", length);
|
const dict = await Dict.from_file("./data/liste_francais.txt", length);
|
||||||
console.log(dict);
|
console.log(dict);
|
||||||
const guesser = new Gueser(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);
|
console.log("Target is", game.word);
|
||||||
let index = 0;
|
let index = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue