11 | $ git clone git@github.com:thomasahle/codenames.git 12 | ... 13 | 14 | $ sh get_glove.sh 15 | ... 16 | 17 | $ python3 codenames.py 18 | ...Loading vectors 19 | ...Loading words 20 | ...Making word to index dict 21 | ...Loading codenames 22 | Ready! 23 | 24 | Will you be agent or spymaster?: agent 25 | 26 | buck bat pumpkin charge iron 27 | well boot chick superhero glove 28 | stream germany sock dragon scientist 29 | duck bugle school ham mammoth 30 | bridge fair triangle capital horn 31 | 32 | Thinking.................... 33 | 34 | Clue: "golden 6" (certainty 7.78, remaining words 8) 35 | 36 | Your guess: bridge 37 | Correct! 38 |39 | 40 | How it works 41 | ============ 42 | The bot decides what words go well together, by comparing their vectors in the GloVe trained on Wikipedia text. 43 | This means that words that often occour in the same articles and sentences are judged to be similar. 44 | In the example about, golden is of course similar to bridge by association with the Golden Gate Bridge. 45 | Other words that were found to be similar were 'dragon', 'triangle', 'duck', 'iron' and 'horn'. 46 | 47 | However, in Codenames the task is not merely to find words that describe other words well. 48 | You also need to make sure that 'bad words' are as different as possible from your clue. 49 | To achieve this, the bot tries to find a word that maximizes the similarity gap between the marked words and the bad words. 50 | 51 | If you want the bot to be more aggressive in its clues (choosing larger groups), try changing the `agg = .5` value near the top of `codenames.py` to a larger value, such as `.8` or `1.5`. 52 | -------------------------------------------------------------------------------- /codenames.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | import numpy as np 4 | import math 5 | 6 | from typing import List, Tuple, Iterable 7 | 8 | # This file stores the "solutions" the bot had intended, 9 | # when you play as agent and the bot as spymaster. 10 | log_file = open("log_file", "w") 11 | 12 | 13 | class Reader: 14 | def read_picks( 15 | self, words: List[str], my_words: Iterable[str], cnt: int 16 | ) -> List[str]: 17 | """ 18 | Query the user for guesses. 19 | :param words: Words the user can choose from. 20 | :param my_words: Correct words. 21 | :param cnt: Number of guesses the user has. 22 | :return: The words picked by the user. 23 | """ 24 | raise NotImplementedError 25 | 26 | def read_clue(self, word_set: Iterable[str]) -> Tuple[str, int]: 27 | """ 28 | Read a clue from the (spymaster) user. 29 | :param word_set: Valid words 30 | :return: The clue and number given. 31 | """ 32 | raise NotImplementedError 33 | 34 | def print_words(self, words: List[str], nrows: int): 35 | """ 36 | Prints a list of words as a 2d table, using `nrows` rows. 37 | :param words: Words to be printed. 38 | :param nrows: Number of rows to print. 39 | """ 40 | raise NotImplementedError 41 | 42 | 43 | class TerminalReader(Reader): 44 | def read_picks( 45 | self, words: List[str], my_words: Iterable[str], cnt: int 46 | ) -> List[str]: 47 | picks = [] 48 | while len(picks) < cnt: 49 | guess = None 50 | while guess not in words: 51 | guess = input("Your guess: ").strip().lower() 52 | picks.append(guess) 53 | if guess in my_words: 54 | print("Correct!") 55 | else: 56 | print("Wrong :(") 57 | break 58 | return picks 59 | 60 | def read_clue(self, word_set) -> Tuple[str, int]: 61 | while True: 62 | inp = input("Clue (e.g. 'car 2'): ").lower() 63 | match = re.match("(\w+)\s+(\d+)", inp) 64 | if match: 65 | clue, cnt = match.groups() 66 | if clue not in word_set: 67 | print("I don't understand that word.") 68 | continue 69 | return clue, int(cnt) 70 | 71 | def print_words(self, words: List[str], nrows: int): 72 | longest = max(map(len, words)) 73 | print() 74 | for row in zip(*(iter(words),) * nrows): 75 | for word in row: 76 | print(word.rjust(longest), end=" ") 77 | print() 78 | print() 79 | 80 | 81 | class Codenames: 82 | def __init__(self, cnt_rows=5, cnt_cols=5, cnt_agents=8, agg=.6, shift=.99): 83 | """ 84 | :param cnt_rows: Number of rows to show. 85 | :param cnt_cols: Number of columns to show. 86 | :param cnt_agents: Number of good words. 87 | :param agg: Agressiveness in [0, infinity). Higher means more aggressive. 88 | """ 89 | self.cnt_rows = cnt_rows 90 | self.cnt_cols = cnt_cols 91 | self.cnt_agents = cnt_agents 92 | self.agg = agg 93 | self.shift = shift 94 | 95 | # Other 96 | self.vectors = np.array([]) 97 | self.word_list = [] 98 | self.weirdness = [] 99 | self.word_to_index = {} 100 | self.codenames = [] 101 | 102 | def load(self, datadir): 103 | # Glove word vectors 104 | print("...Loading vectors") 105 | self.vectors = np.load(f"{datadir}/glove.6B.300d.npy") 106 | 107 | # List of all glove words 108 | print("...Loading words") 109 | self.word_list = [w.lower().strip() for w in open(f"{datadir}/words")] 110 | self.weirdness = [math.log(i + 1) + 1 for i in range(len(self.word_list))] 111 | 112 | # Indexing back from word to indices 113 | print("...Making word to index dict") 114 | self.word_to_index = {w: i for i, w in enumerate(self.word_list)} 115 | 116 | # Get rid of stupid hints like "the" 117 | self.stopwords = [w.strip() for w in open('stopwords')] 118 | for w in self.stopwords: 119 | self.weirdness[self.word_to_index[w]] += 5 120 | 121 | # All words that are allowed to go onto the table 122 | print("...Loading codenames") 123 | self.codenames: List[str] = [ 124 | word 125 | for word in (w.lower().strip().replace(" ", "-") for w in open("wordlist2")) 126 | if word in self.word_to_index 127 | ] 128 | 129 | print("Ready!") 130 | 131 | def word_to_vector(self, word: str) -> np.ndarray: 132 | """ 133 | :param word: To be vectorized. 134 | :return: The vector. 135 | """ 136 | return self.vectors[self.word_to_index[word]] 137 | 138 | def most_similar_to_given(self, clue: str, choices: List[str]) -> str: 139 | """ 140 | :param clue: Clue from the spymaster. 141 | :param choices: Choices on the table. 142 | :return: Which choice to go for. 143 | """ 144 | clue_vector = self.word_to_vector(clue) 145 | return max(choices, key=lambda w: self.word_to_vector(w) @ clue_vector) 146 | 147 | def find_clue( 148 | self, words: List[str], my_words: List[str], black_list: Iterable[str], 149 | verbose: bool=False, 150 | ) -> Tuple[str, float, List[str]]: 151 | """ 152 | :param words: Words on the board. 153 | :param my_words: Words we want to guess. 154 | :param black_list: Clues we are not allowed to give. 155 | :return: (The best clue, the score, the words we expect to be guessed) 156 | """ 157 | if verbose: 158 | print("Thinking", end="", flush=True) 159 | 160 | # Words to avoid the agent guessing. 161 | negs = [w for w in words if w not in my_words] 162 | # Worst (highest) inner product with negative words 163 | if negs: 164 | nm = ( 165 | self.vectors @ np.array([self.word_to_vector(word) for word in negs]).T 166 | ).max(axis=1) 167 | else: 168 | # The case where we only have my_words left 169 | nm = [-1000] * len(self.word_list) 170 | # Inner product with positive words 171 | pm = self.vectors @ np.array([self.word_to_vector(word) for word in my_words]).T 172 | 173 | best_clue, best_score, best_k, best_g = None, -100, 0, () 174 | for step, (clue, lower_bound, scores) in enumerate(zip(self.word_list, nm, pm)): 175 | if verbose and step % 20000 == 0: 176 | print(".", end="", flush=True) 177 | 178 | # If the best score is lower than the lower bound, there is no reason 179 | # to even try it. 180 | if max(scores) <= lower_bound or clue in black_list: 181 | continue 182 | 183 | # Order scores by lowest to highest inner product with the clue. 184 | ss = sorted((s, i) for i, s in enumerate(scores)) 185 | # Calculate the "real score" by 186 | # (lowest score in group) * [ (group size)^aggressiveness - 1]. 187 | # The reason we subtract one is that we never want to have a group of 188 | # size 1. 189 | # We divide by log(step), as to not show too many 'weird' words. 190 | real_score, j = max( 191 | ( 192 | (s - lower_bound) 193 | * ((len(ss) - j) ** self.agg - self.shift) 194 | / self.weirdness[step], 195 | j, 196 | ) 197 | for j, (s, _) in enumerate(ss) 198 | ) 199 | 200 | if real_score > best_score: 201 | group = [my_words[i] for _, i in ss[j:]] 202 | best_clue, best_score, best_k, best_g = ( 203 | clue, 204 | real_score, 205 | len(group), 206 | group, 207 | ) 208 | 209 | # After printing '.'s with end="" we need a clean line. 210 | if verbose: 211 | print() 212 | 213 | return best_clue, best_score, best_g 214 | 215 | def play_spymaster(self, reader: Reader): 216 | """ 217 | Play a complete game, with the robot being the spymaster. 218 | """ 219 | words = random.sample(self.codenames, self.cnt_rows * self.cnt_cols) 220 | my_words = set(random.sample(words, self.cnt_agents)) 221 | used_clues = set(my_words) 222 | while my_words: 223 | reader.print_words(words, nrows=self.cnt_rows) 224 | 225 | clue, score, group = self.find_clue(words, list(my_words), used_clues) 226 | # Print the clue to the log_file for "debugging" purposes 227 | group_scores = np.array( 228 | [self.word_to_vector(w) for w in group] 229 | ) @ self.word_to_vector(clue) 230 | print(clue, group, group_scores, file=log_file, flush=True) 231 | # Save the clue, so we don't use it again 232 | used_clues.add(clue) 233 | 234 | print() 235 | print( 236 | 'Clue: "{} {}" (certainty {:.2f}, remaining words {})'.format( 237 | clue, len(group), score, len(my_words) 238 | ) 239 | ) 240 | print() 241 | for pick in reader.read_picks(words, my_words, len(group)): 242 | words[words.index(pick)] = "---" 243 | if pick in my_words: 244 | my_words.remove(pick) 245 | 246 | def play_agent(self, reader: Reader): 247 | """ 248 | Play a complete game, with the robot being the agent. 249 | """ 250 | words = random.sample(self.codenames, self.cnt_rows * self.cnt_cols) 251 | my_words = random.sample(words, self.cnt_agents) 252 | picked = [] 253 | while any(w not in picked for w in my_words): 254 | reader.print_words( 255 | [w if w not in picked else "---" for w in words], nrows=self.cnt_rows 256 | ) 257 | print("Your words:", ", ".join(w for w in my_words if w not in picked)) 258 | clue, cnt = reader.read_clue(self.word_to_index.keys()) 259 | for _ in range(cnt): 260 | guess = self.most_similar_to_given( 261 | clue, [w for w in words if w not in picked] 262 | ) 263 | picked.append(guess) 264 | answer = input("I guess {}? [Y/n]: ".format(guess)) 265 | if answer == "n": 266 | print("Sorry about that.") 267 | break 268 | else: 269 | print("I got them all!") 270 | 271 | 272 | def main(): 273 | cn = Codenames() 274 | cn.load("dataset") 275 | reader = TerminalReader() 276 | while True: 277 | try: 278 | mode = input("\nWill you be agent or spymaster?: ") 279 | except KeyboardInterrupt: 280 | print("\nGoodbye!") 281 | break 282 | try: 283 | if mode == "spymaster": 284 | cn.play_agent(reader) 285 | elif mode == "agent": 286 | cn.play_spymaster(reader) 287 | except KeyboardInterrupt: 288 | # Catch interrupts from play functions 289 | pass 290 | 291 | 292 | if __name__ == '__main__': 293 | main() 294 | 295 | -------------------------------------------------------------------------------- /compress.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import numpy as np 4 | import argparse 5 | import tqdm 6 | import re 7 | 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument("vectors", help="Vectors to compress") 10 | parser.add_argument("words", help="The coresponding words, because we do filtering") 11 | parser.add_argument("-n", default=50000, type=int, help="Number of words to include") 12 | parser.add_argument("-f", type=str, nargs='*', help="Take only words from this file") 13 | 14 | def quantize_8bit(data, alpha): 15 | # Normalize data to 0-1 16 | min_val = np.min(data) * alpha 17 | max_val = np.max(data) * alpha 18 | data = data.clip(min_val, max_val) 19 | normalized = (data - min_val) / (max_val - min_val) 20 | # Scale to 0-255 and convert to uint8 21 | quantized = (normalized * 255).astype(np.uint8) 22 | return quantized, min_val, max_val 23 | 24 | def dequantize_8bit(quantized, min_val, max_val): 25 | # Convert back to float range 0-1 26 | normalized = quantized.astype(np.float32) / 255 27 | # Scale back to original range 28 | dequantized = normalized * (max_val - min_val) + min_val 29 | return dequantized 30 | 31 | def main(args): 32 | vecs = np.load(args.vectors) 33 | 34 | n, dim = vecs.shape 35 | if dim != 300: 36 | from sklearn.decomposition import PCA 37 | vecs = PCA(n_components=300).fit_transform(vecs) 38 | 39 | # vecs /= np.linalg.norm(vecs, axis=1, keepdims=True) 40 | with open(args.words) as file: 41 | words = file.readlines() 42 | 43 | if not args.f: 44 | good_words = set(words) 45 | else: 46 | good_words = set() 47 | print(args.f) 48 | for path in args.f: 49 | with open(path) as file: 50 | good_words |= {line.lower().strip() for line in file} 51 | print(f'{len(good_words)=}') 52 | print(list(good_words)[:3]) 53 | 54 | print(len(vecs), len(words)) 55 | assert len(vecs) == len(words) 56 | 57 | included_vectors = [] 58 | included_words = [] 59 | seen = set() 60 | for vec, word in zip(vecs, tqdm.tqdm(words, total=args.n)): 61 | word = word.lower() 62 | word = re.sub('[^a-z0-9]', '', word) 63 | if not word or word.isdigit(): 64 | continue 65 | if word in seen: 66 | continue 67 | if good_words and word not in good_words: 68 | continue 69 | seen.add(word) 70 | included_vectors.append(vec) 71 | included_words.append(word) 72 | if len(included_words) == args.n: 73 | break 74 | 75 | x = np.stack(included_vectors) 76 | 77 | best_alpha, best_err = 0, 1000 78 | for alpha in tqdm.tqdm(np.linspace(x.std()/np.abs(x).max(), 1)): 79 | compressed, min_val, max_val = quantize_8bit(x, alpha) 80 | restored = dequantize_8bit(compressed, min_val, max_val) 81 | err = np.linalg.norm(x - restored, axis=1) / np.linalg.norm(x, axis=1) 82 | #merr = (err**2).mean() 83 | merr = err.mean() 84 | if merr < best_err: 85 | best_err = merr 86 | best_alpha = alpha 87 | print(f"{alpha}, Mean error: {merr}") 88 | print(f"{best_alpha=}") 89 | 90 | compressed, min_val, max_val = quantize_8bit(x, best_alpha) 91 | print("IMPORTANT:") 92 | print(f"min={min_val}, max={max_val}") 93 | 94 | restored = dequantize_8bit(compressed, min_val, max_val) 95 | err = np.linalg.norm(x - restored, axis=1) / np.linalg.norm(x, axis=1) 96 | print(f"Mean error: {err.mean()}") 97 | 98 | data = compressed.tobytes() 99 | print(f"Size: {len(data)/10**6}MB") 100 | 101 | with open(f'{args.vectors}.out', 'wb') as file: 102 | file.write(data) 103 | with open(f'{args.words}.out', 'w') as file: 104 | file.write("\n".join(included_words)) 105 | 106 | if __name__ == '__main__': 107 | main(parser.parse_args()) 108 | -------------------------------------------------------------------------------- /convert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import tqdm 4 | import sys 5 | import numpy as np 6 | import argparse 7 | 8 | parser = argparse.ArgumentParser(description="Process GloVe dataset.") 9 | parser.add_argument("input", help="Path to the input GloVe text file.") 10 | parser.add_argument("--dim", default=300, help="Expected dimension of each vector") 11 | parser.add_argument("-v", "--output-vectors", help="Path to the output numpy matrix file.", required=True) 12 | parser.add_argument("-w", "--output-words", help="Path to the output words file.", required=True) 13 | args = parser.parse_args() 14 | 15 | matrix = [] 16 | words = [] 17 | with open(args.input, 'r') as inf: 18 | for counter, line in enumerate(tqdm.tqdm(inf)): 19 | word, *rest = line.split() 20 | try: 21 | row = list(map(float, rest)) 22 | except ValueError: 23 | print(f'Bad vector for {repr(word)}. Skipping') 24 | continue 25 | if len(row) != args.dim: 26 | print(f'Bad vector length for {repr(word)}. Skipping') 27 | continue 28 | words.append(word) 29 | matrix.append(np.array(row, dtype=np.float32)) 30 | 31 | np.save(args.output_vectors, np.array(matrix)) 32 | 33 | with open(args.output_words, 'w') as ouf: 34 | ouf.write('\n'.join(words)) 35 | -------------------------------------------------------------------------------- /get_glove.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | mkdir dataset 3 | cd dataset 4 | wget --no-check-certificate http://nlp.stanford.edu/data/glove.6B.zip 5 | unzip glove.6B.zip glove.6B.300d.txt 6 | rm glove.6B.zip 7 | ../convert.py glove.6B.300d.txt -v glove.6B.300d.npy -w words 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 15 | 16 | 17 | 18 | 19 |
See the clues in Statistics
56 |Come back here after the game.
"; 525 | } 526 | } 527 | 528 | // Show help on first visit 529 | if (localStorage.getItem('hasVisited') === null) { 530 | data.helpShown = true; 531 | render(); 532 | localStorage.setItem('hasVisited', 'true'); 533 | } 534 | } 535 | 536 | function fetchWords(path) { 537 | return fetch(path) 538 | .then(response => response.text()) 539 | .then(text => text.split('\n').filter(word => word.trim().length > 0)) 540 | .catch(error => { 541 | console.error('Error fetching or processing the file:', error); 542 | }); 543 | } 544 | 545 | function fetchWordsGz(path) { 546 | return fetch(path) 547 | .then(response => response.body) 548 | .then(stream => { 549 | const decompressionStream = new DecompressionStream('gzip'); 550 | const decompressedStream = stream.pipeThrough(decompressionStream); 551 | return new Response(decompressedStream).text(); 552 | }) 553 | .then(text => text.split('\n')) 554 | .catch(error => { 555 | console.error('Error fetching or processing the file:', error); 556 | }); 557 | } 558 | 559 | function fetchVectors(path) { 560 | console.log(`Loading model ${path}`); 561 | return fetch(path) 562 | .then(response => response.body) 563 | .then(stream => { 564 | const decompressionStream = new DecompressionStream('gzip'); 565 | const decompressedStream = stream.pipeThrough(decompressionStream); 566 | return new Response(decompressedStream).arrayBuffer(); 567 | }) 568 | .then(decompressedBuffer => { 569 | const dim = 300; 570 | const rows = 9910; 571 | const byteArray = new Uint8Array(decompressedBuffer); 572 | 573 | // Glove: 574 | // avg 5.77 575 | // win@6 0.67 576 | //const min_val=-2.645588700353074; 577 | //const max_val=2.6333964024164196; 578 | 579 | // Angel, PCA: 580 | // avg 5.62 - 5.68 581 | // win@6 0.688 - 0.696 582 | // Avg: 1.980 - 2.002 583 | // const min_val=-3.508529352673804; 584 | // const max_val=4.6301482913369485; 585 | 586 | // Patched, angel2, lora 587 | // avg 5.5 588 | // win@6 0.714 589 | // Avg: 1.991 590 | //const min_val=-6.580836296081543; 591 | //const max_val=8.107464790344238; 592 | 593 | // angel3 594 | // avg game 5.37 595 | // win@6 0.765 596 | // Avg clue: 1.883 597 | const min_val=-1.7025203704833984; 598 | const max_val=1.5609053373336792; 599 | 600 | // Dequantize 601 | const quantizedMatrix = mlMatrix.Matrix.from1DArray(rows, dim, byteArray); 602 | let matrix = quantizedMatrix.div(255).mul(max_val - min_val).add(min_val); 603 | 604 | // Normalize 605 | for (let i = 0; i < matrix.rows; i++) { 606 | let row = matrix.getRow(i); 607 | let norm = Math.sqrt(row.reduce((sum, value) => sum + value * value, 0)); 608 | matrix.setRow(i, row.map(value => value / norm)); 609 | } 610 | return matrix; 611 | }) 612 | .catch(error => console.error('Error loading file:', error)); 613 | } 614 | 615 | function sample(key, originalArray, n) { 616 | // Certified random coefficients from random.org 617 | const cs = [82304423, 346724810, 725211102, 50719932, 978969693, 1594878607]; 618 | 619 | // Polynomial random generator 620 | function next() { 621 | let result = cs[0]; 622 | for (let i = 1; i < cs.length; i++) { 623 | result = result * key + cs[i]; 624 | result %= 2147483647; 625 | } 626 | key += 1; // Increment the key for the next call 627 | return result; 628 | } 629 | 630 | const array = [...originalArray]; 631 | for (let i = 0; i < Math.min(n, array.length); i++) { 632 | const j = i + next() % (array.length - i); 633 | [array[i], array[j]] = [array[j], array[i]]; 634 | } 635 | return array.slice(0, n) 636 | } 637 | 638 | 639 | function findVector(words, word) { 640 | let index = words.indexOf(word.toLowerCase()); 641 | if (index == -1) { 642 | console.log(`Can't find ${word}`); 643 | index = 0; 644 | } 645 | return index; 646 | } 647 | 648 | function makeHint(matrix, words, stopwords, board, secret, aggressiveness, shift) { 649 | /* The algorithm uses the following formula for scoring clues: 650 | * gap * (n^agg - shift) 651 | * Where `gap` is the gap in inner products between the worst "good" word 652 | * and the best "bad" word. 653 | * `n` is the size of the clue, and agg is the aggressiveness. 654 | * 655 | * So if agg = 0, we only look at the `gap`. 656 | * If agg = inf, we only care about `n`. 657 | * Default agg should be around 0.6. 658 | */ 659 | console.log("Thinking..."); 660 | 661 | const avoids = board.filter(word => !secret.includes(word)); 662 | console.log(avoids); 663 | const badVectors = new mlMatrix.MatrixRowSelectionView(matrix, 664 | avoids.map(word => findVector(words, word))); 665 | const goodVectors = new mlMatrix.MatrixRowSelectionView(matrix, 666 | secret.map(word => findVector(words, word))); 667 | 668 | // For any clue (row) vector, we want to find the largest IP with a bad 669 | // vector, since since that sets a lower bound on the IPs we are willing 670 | // to accept. This comes from the "zero bad vectors accepted" requirement. 671 | const nm = matrix.mmul(badVectors.transpose()).max('row'); 672 | // For the good vectors, we want to know the IP with each one, so this is 673 | // a matrix of shape |words| x |secret| 674 | const pm = matrix.mmul(goodVectors.transpose()); 675 | 676 | let best = {}; 677 | for (let step = 0; step < words.length; step++) { 678 | const clue = words[step]; 679 | let lowerBound = Math.max(nm[step] || 0, 0); 680 | const scores = pm.getRow(step); 681 | 682 | // TODO: Maybe sometimes it's OK to include a single bad word with a high score, 683 | // if the `n` is large enough? 684 | // Could test this with GPT. 685 | 686 | // If the best score is lower than the lower bound, there is no reason 687 | // to even try it. 688 | if (stopwords.includes(clue)) { 689 | continue; 690 | } 691 | // Don't use something directly present on the board 692 | let skip = false; 693 | for (const word of board) { 694 | skip = skip || word.includes(clue.toUpperCase()); 695 | skip = skip || clue.toUpperCase().includes(word); 696 | } 697 | if (skip) { 698 | continue; 699 | } 700 | 701 | // Order scores by highest to lowest inner product with the clue. 702 | const ss = scores 703 | .map((score, i) => ({ score, index: i })) 704 | .sort((a, b) => b.score - a.score); 705 | 706 | for (let j = 0; j < ss.length; j++) { 707 | const gap = ss[j].score - lowerBound; 708 | 709 | // Save that we can achieve gap s-lowerBound with n=j+1 710 | if (!best[j] || gap > best[j].gap) { 711 | best[j] = {gap, clue, scores, lowerBound}; 712 | } 713 | } 714 | } 715 | 716 | let combinedBest = {combinedScore: -10000}; 717 | for (let [n, {gap, clue, scores, lowerBound}] of Object.entries(best)) { 718 | n = parseInt(n); 719 | console.log(`N: ${n+1}, Gap: ${gap}, Clue: ${clue}, Lb: ${lowerBound}`); 720 | console.log(scores); 721 | let combinedScore = gap * (Math.pow(n+1, aggressiveness) - shift); 722 | console.log(`Combined Score: ${combinedScore}`); 723 | 724 | let indices = [...scores.keys()]; 725 | indices.sort((a, b) => scores[b] - scores[a]); 726 | let largestIndices = indices.slice(0, n+1); 727 | let intendedClues = largestIndices.map(i => secret[i]); 728 | 729 | if (combinedScore > combinedBest.combinedScore) { 730 | combinedBest = {n: n+1, clue, intendedClues, combinedScore}; 731 | } 732 | } 733 | 734 | return combinedBest; 735 | } 736 | 737 | function getGuessesPerRound(hints, revealed, secret) { 738 | let j = 0; 739 | const result = []; 740 | for (const hint of hints) { 741 | const guesses = []; 742 | while ( 743 | j !== revealed.length // Not through all actions yet. 744 | && secret.includes(revealed[j]) // No mistake yet. 745 | && guesses.length != hint.n // Haven't finished by success. 746 | ) { 747 | guesses.push(revealed[j]); 748 | j++; 749 | } 750 | // Maybe add a mistake 751 | if (j !== revealed.length && guesses.length != hint.n) { 752 | guesses.push(revealed[j]); 753 | j++; 754 | } 755 | result.push(guesses); 756 | } 757 | // If we just started a new round 758 | if (result.length == hints.length-1) { 759 | result.push([]); 760 | } 761 | if (result.length != hints.length) { 762 | console.log(`Bad length ${result.length}, ${hints.length}.`); 763 | } 764 | return result; 765 | 766 | } 767 | 768 | function compileLog(hints, revealed, secret) { 769 | let shareString = ""; 770 | let s = "Round ${i + 1} Clue: ${hint.clue.toUpperCase()} ${hint.n}
`; 775 | s += "