wordle_decoder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ ["cigar","rebut","sissy","humph","awake","blush","focal","evade","naval","serve","heath","dwarf","model","karma","stink","grade","quiet","bench","abate","feign","major","death","fresh","crust","stool","colon","abase","marry","react","batty","pride","floss","helix","croak","staff","paper","unfed","whelp","trawl","outdo","adobe","crazy","sower","repay","digit","crate","cluck","spike","mimic","pound","maxim","linen","unmet","flesh","booby","forth","first","stand","belly","ivory","seedy","print","yearn","drain","bribe","stout","panel","crass","flume","offal","agree","error","swirl","argue","bleed","delta","flick","totem","wooer","front","shrub","parry","biome","lapel","start","greet","goner","golem","lusty","loopy","round","audit","lying","gamma","labor","islet","civic","forge","corny","moult","basic","salad","agate","spicy","spray","essay","fjord","spend","kebab","guild","aback","motor","alone","hatch","hyper","thumb","dowry","ought","belch","dutch","pilot","tweed","comet","jaunt","enema","steed","abyss","growl","fling","dozen","boozy","erode","world","gouge","click","briar","great","altar","pulpy","blurt","coast","duchy","groin","fixer","group","rogue","badly","smart","pithy","gaudy","chill","heron","vodka","finer","surer","radio","rouge","perch","retch","wrote","clock","tilde","store","prove","bring","solve","cheat","grime","exult","usher","epoch","triad","break","rhino","viral","conic","masse","sonic","vital","trace","using","peach","champ","baton","brake","pluck","craze","gripe","weary","picky","acute","ferry","aside","tapir","troll","unify","rebus","boost","truss","siege","tiger","banal","slump","crank","gorge","query","drink","favor","abbey","tangy","panic","solar","shire","proxy","point","robot","prick","wince","crimp","knoll","sugar","whack","mount","perky","could","wrung","light","those","moist","shard","pleat","aloft","skill","elder","frame","humor","pause","ulcer","ultra","robin","cynic","aroma","caulk","shake","dodge","swill","tacit","other","thorn","trove","bloke","vivid","spill","chant","choke","rupee","nasty","mourn","ahead","brine","cloth","hoard","sweet","month","lapse","watch","today","focus","smelt","tease","cater","movie","saute","allow","renew","their","slosh","purge","chest","depot","epoxy","nymph","found","shall","harry","stove","lowly","snout","trope","fewer","shawl","natal","comma","foray","scare","stair","black","squad","royal","chunk","mince","shame","cheek","ample","flair","foyer","cargo","oxide","plant","olive","inert","askew","heist","shown","zesty","hasty","trash","fella","larva","forgo","story","hairy","train","homer","badge","midst","canny","fetus","butch","farce","slung","tipsy","metal","yield","delve","being","scour","glass","gamer","scrap","money","hinge","album","vouch","asset","tiara","crept","bayou","atoll","manor","creak","showy","phase","froth","depth","gloom","flood","trait","girth","piety","payer","goose","float","donor","atone","primo","apron","blown","cacao","loser","input","gloat","awful","brink","smite","beady","rusty","retro","droll","gawky","hutch","pinto","gaily","egret","lilac","sever","field","fluff","hydro","flack","agape","voice","stead","stalk","berth","madam","night","bland","liver","wedge","augur","roomy","wacky","flock","angry","bobby","trite","aphid","tryst","midge","power","elope","cinch","motto","stomp","upset","bluff","cramp","quart","coyly","youth","rhyme","buggy","alien","smear","unfit","patty","cling","glean","label","hunky","khaki","poker","gruel","twice","twang","shrug","treat","unlit","waste","merit","woven","octal","needy","clown","widow","irony","ruder","gauze","chief","onset","prize","fungi","charm","gully","inter","whoop","taunt","leery","class","theme","lofty","tibia","booze","alpha","thyme","eclat","doubt","parer","chute","stick","trice","alike","sooth","recap","saint","liege","glory","grate","admit","brisk","soggy","usurp","scald","scorn","leave","twine","sting","bough","marsh","sloth","dandy","vigor","howdy","enjoy","valid","ionic","equal","unset","floor","catch","spade","stein","exist","quirk","denim","grove","spiel","mummy","fault","foggy","flout","carry","sneak","libel","waltz","aptly","piney","inept","aloud","photo","dream","stale","vomit","ombre","fanny","unite","snarl","baker","there","glyph","pooch","hippy","spell","folly","louse","gulch","vault","godly","threw","fleet","grave","inane","shock","crave","spite","valve","skimp","claim","rainy","musty","pique","daddy","quasi","arise","aging","valet","opium","avert","stuck","recut","mulch","genre","plume","rifle","count","incur","total","wrest","mocha","deter","study","lover","safer","rivet","funny","smoke","mound","undue","sedan","pagan","swine","guile","gusty","equip","tough","canoe","chaos","covet","human","udder","lunch","blast","stray","manga","melee","lefty","quick","paste","given","octet","risen","groan","leaky","grind","carve","loose","sadly","spilt","apple","slack","honey","final","sheen","eerie","minty","slick","derby","wharf","spelt","coach","erupt","singe","price","spawn","fairy","jiffy","filmy","stack","chose","sleep","ardor","nanny","niece","woozy","handy","grace","ditto","stank","cream","usual","diode","valor","angle","ninja","muddy","chase","reply","prone","spoil","heart","shade","diner","arson","onion","sleet","dowel","couch","palsy","bowel","smile","evoke","creek","lance","eagle","idiot","siren","built","embed","award","dross","annul","goody","frown","patio","laden","humid","elite","lymph","edify","might","reset","visit","gusto","purse","vapor","crock","write","sunny","loath","chaff","slide","queer","venom","stamp","sorry","still","acorn","aping","pushy","tamer","hater","mania","awoke","brawn","swift","exile","birch","lucky","freer","risky","ghost","plier","lunar","winch","snare","nurse","house","borax","nicer","lurch","exalt","about","savvy","toxin","tunic","pried","inlay","chump","lanky","cress","eater","elude","cycle","kitty","boule","moron","tenet","place","lobby","plush","vigil","index","blink","clung","qualm","croup","clink","juicy","stage","decay","nerve","flier","shaft","crook","clean","china","ridge","vowel","gnome","snuck","icing","spiny","rigor","snail","flown","rabid","prose","thank","poppy","budge","fiber","moldy","dowdy","kneel","track","caddy","quell","dumpy","paler","swore","rebar","scuba","splat","flyer","horny","mason","doing","ozone","amply","molar","ovary","beset","queue","cliff","magic","truce","sport","fritz","edict","twirl","verse","llama","eaten","range","whisk","hovel","rehab","macaw","sigma","spout","verve","sushi","dying","fetid","brain","buddy","thump","scion","candy","chord","basin","march","crowd","arbor","gayly","musky","stain","dally","bless","bravo","stung","title","ruler","kiosk","blond","ennui","layer","fluid","tatty","score","cutie","zebra","barge","matey","bluer","aider","shook","river","privy","betel","frisk","bongo","begun","azure","weave","genie","sound","glove","braid","scope","wryly","rover","assay","ocean","bloom","irate","later","woken","silky","wreck","dwelt","slate","smack","solid","amaze","hazel","wrist","jolly","globe","flint","rouse","civil","vista","relax","cover","alive","beech","jetty","bliss","vocal","often","dolly","eight","joker","since","event","ensue","shunt","diver","poser","worst","sweep","alley","creed","anime","leafy","bosom","dunce","stare","pudgy","waive","choir","stood","spoke","outgo","delay","bilge","ideal","clasp","seize","hotly","laugh","sieve","block","meant","grape","noose","hardy","shied","drawl","daisy","putty","strut","burnt","tulip","crick","idyll","vixen","furor","geeky","cough","naive","shoal","stork","bathe","aunty","check","prime","brass","outer","furry","razor","elect","evict","imply","demur","quota","haven","cavil","swear","crump","dough","gavel","wagon","salon","nudge","harem","pitch","sworn","pupil","excel","stony","cabin","unzip","queen","trout","polyp","earth","storm","until","taper","enter","child","adopt","minor","fatty","husky","brave","filet","slime","glint","tread","steal","regal","guest","every","murky","share","spore","hoist","buxom","inner","otter","dimly","level","sumac","donut","stilt","arena","sheet","scrub","fancy","slimy","pearl","silly","porch","dingo","sepia","amble","shady","bread","friar","reign","dairy","quill","cross","brood","tuber","shear","posit","blank","villa","shank","piggy","freak","which","among","fecal","shell","would","algae","large","rabbi","agony","amuse","bushy","copse","swoon","knife","pouch","ascot","plane","crown","urban","snide","relay","abide","viola","rajah","straw","dilly","crash","amass","third","trick","tutor","woody","blurb","grief","disco","where","sassy","beach","sauna","comic","clued","creep","caste","graze","snuff","frock","gonad","drunk","prong","lurid","steel","halve","buyer","vinyl","utile","smell","adage","worry","tasty","local","trade","finch","ashen","modal","gaunt","clove","enact","adorn","roast","speck","sheik","missy","grunt","snoop","party","touch","mafia","emcee","array","south","vapid","jelly","skulk","angst","tubal","lower","crest","sweat","cyber","adore","tardy","swami","notch","groom","roach","hitch","young","align","ready","frond","strap","puree","realm","venue","swarm","offer","seven","dryer","diary","dryly","drank","acrid","heady","theta","junto","pixie","quoth","bonus","shalt","penne","amend","datum","build","piano","shelf","lodge","suing","rearm","coral","ramen","worth","psalm","infer","overt","mayor","ovoid","glide","usage","poise","randy","chuck","prank","fishy","tooth","ether","drove","idler","swath","stint","while","begat","apply","slang","tarot","radar","credo","aware","canon","shift","timer","bylaw","serum","three","steak","iliac","shirk","blunt","puppy","penal","joist","bunny","shape","beget","wheel","adept","stunt","stole","topaz","chore","fluke","afoot","bloat","bully","dense","caper","sneer","boxer","jumbo","lunge","space","avail","short","slurp","loyal","flirt","pizza","conch","tempo","droop","plate","bible","plunk","afoul","savoy","steep","agile","stake","dwell","knave","beard","arose","motif","smash","broil","glare","shove","baggy","mammy","swamp","along","rugby","wager","quack","squat","snaky","debit","mange","skate","ninth","joust","tramp","spurn","medal","micro","rebel","flank","learn","nadir","maple","comfy","remit","gruff","ester","least","mogul","fetch","cause","oaken","aglow","meaty","gaffe","shyly","racer","prowl","thief","stern","poesy","rocky","tweet","waist","spire","grope","havoc","patsy","truly","forty","deity","uncle","swish","giver","preen","bevel","lemur","draft","slope","annoy","lingo","bleak","ditty","curly","cedar","dirge","grown","horde","drool","shuck","crypt","cumin","stock","gravy","locus","wider","breed","quite","chafe","cache","blimp","deign","fiend","logic","cheap","elide","rigid","false","renal","pence","rowdy","shoot","blaze","envoy","posse","brief","never","abort","mouse","mucky","sulky","fiery","media","trunk","yeast","clear","skunk","scalp","bitty","cider","koala","duvet","segue","creme","super","grill","after","owner","ember","reach","nobly","empty","speed","gipsy","recur","smock","dread","merge","burst","kappa","amity","shaky","hover","carol","snort","synod","faint","haunt","flour","chair","detox","shrew","tense","plied","quark","burly","novel","waxen","stoic","jerky","blitz","beefy","lyric","hussy","towel","quilt","below","bingo","wispy","brash","scone","toast","easel","saucy","value","spice","honor","route","sharp","bawdy","radii","skull","phony","issue","lager","swell","urine","gassy","trial","flora","upper","latch","wight","brick","retry","holly","decal","grass","shack","dogma","mover","defer","sober","optic","crier","vying","nomad","flute","hippo","shark","drier","obese","bugle","tawny","chalk","feast","ruddy","pedal","scarf","cruel","bleat","tidal","slush","semen","windy","dusty","sally","igloo","nerdy","jewel","shone","whale","hymen","abuse","fugue","elbow","crumb","pansy","welsh","syrup","terse","suave","gamut","swung","drake","freed","afire","shirt","grout","oddly","tithe","plaid","dummy","broom","blind","torch","enemy","again","tying","pesky","alter","gazer","noble","ethos","bride","extol","decor","hobby","beast","idiom","utter","these","sixth","alarm","erase","elegy","spunk","piper","scaly","scold","hefty","chick","sooty","canal","whiny","slash","quake","joint","swept","prude","heavy","wield","femme","lasso","maize","shale","screw","spree","smoky","whiff","scent","glade","spent","prism","stoke","riper","orbit","cocoa","guilt","humus","shush","table","smirk","wrong","noisy","alert","shiny","elate","resin","whole","hunch","pixel","polar","hotel","sword","cleat","mango","rumba","puffy","filly","billy","leash","clout","dance","ovate","facet","chili","paint","liner","curio","salty","audio","snake","fable","cloak","navel","spurt","pesto","balmy","flash","unwed","early","churn","weedy","stump","lease","witty","wimpy","spoof","saner","blend","salsa","thick","warty","manic","blare","squib","spoon","probe","crepe","knack","force","debut","order","haste","teeth","agent","widen","icily","slice","ingot","clash","juror","blood","abode","throw","unity","pivot","slept","troop","spare","sewer","parse","morph","cacti","tacky","spool","demon","moody","annex","begin","fuzzy","patch","water","lumpy","admin","omega","limit","tabby","macho","aisle","skiff","basis","plank","verge","botch","crawl","lousy","slain","cubic","raise","wrack","guide","foist","cameo","under","actor","revue","fraud","harpy","scoop","climb","refer","olden","clerk","debar","tally","ethic","cairn","tulle","ghoul","hilly","crude","apart","scale","older","plain","sperm","briny","abbot","rerun","quest","crisp","bound","befit","drawn","suite","itchy","cheer","bagel","guess","broad","axiom","chard","caput","leant","harsh","curse","proud","swing","opine","taste","lupus","gumbo","miner","green","chasm","lipid","topic","armor","brush","crane","mural","abled","habit","bossy","maker","dusky","dizzy","lithe","brook","jazzy","fifty","sense","giant","surly","legal","fatal","flunk","began","prune","small","slant","scoff","torus","ninny","covey","viper","taken","moral","vogue","owing","token","entry","booth","voter","chide","elfin","ebony","neigh","minim","melon","kneed","decoy","voila","ankle","arrow","mushy","tribe","cease","eager","birth","graph","odder","terra","weird","tried","clack","color","rough","weigh","uncut","ladle","strip","craft","minus","dicey","titan","lucid","vicar","dress","ditch","gypsy","pasta","taffy","flame","swoop","aloof","sight","broke","teary","chart","sixty","wordy","sheer","leper","nosey","bulge","savor","clamp","funky","foamy","toxic","brand","plumb","dingy","butte","drill","tripe","bicep","tenor","krill","worse","drama","hyena","think","ratio","cobra","basil","scrum","bused","phone","court","camel","proof","heard","angel","petal","pouty","throb","maybe","fetal","sprig","spine","shout","cadet","macro","dodgy","satyr","rarer","binge","trend","nutty","leapt","amiss","split","myrrh","width","sonar","tower","baron","fever","waver","spark","belie","sloop","expel","smote","baler","above","north","wafer","scant","frill","awash","snack","scowl","frail","drift","limbo","fence","motel","ounce","wreak","revel","talon","prior","knelt","cello","flake","debug","anode","crime","salve","scout","imbue","pinky","stave","vague","chock","fight","video","stone","teach","cleft","frost","prawn","booty","twist","apnea","stiff","plaza","ledge","tweak","board","grant","medic","bacon","cable","brawl","slunk","raspy","forum","drone","women","mucus","boast","toddy","coven","tumor","truer","wrath","stall","steam","axial","purer","daily","trail","niche","mealy","juice","nylon","plump","merry","flail","papal","wheat","berry","cower","erect","brute","leggy","snipe","sinew","skier","penny","jumpy","rally","umbra","scary","modem","gross","avian","greed","satin","tonic","parka","sniff","livid","stark","trump","giddy","reuse","taboo","avoid","quote","devil","liken","gloss","gayer","beret","noise","gland","dealt","sling","rumor","opera","thigh","tonga","flare","wound","white","bulky","etude","horse","circa","paddy","inbox","fizzy","grain","exert","surge","gleam","belle","salvo","crush","fruit","sappy","taker","tract","ovine","spiky","frank","reedy","filth","spasm","heave","mambo","right","clank","trust","lumen","borne","spook","sauce","amber","lathe","carat","corer","dirty","slyly","affix","alloy","taint","sheep","kinky","wooly","mauve","flung","yacht","fried","quail","brunt","grimy","curvy","cagey","rinse","deuce","state","grasp","milky","bison","graft","sandy","baste","flask","hedge","girly","swash","boney","coupe","endow","abhor","welch","blade","tight","geese","miser","mirth","cloud","cabal","leech","close","tenth","pecan","droit","grail","clone","guise","ralph","tango","biddy","smith","mower","payee","serif","drape","fifth","spank","glaze","allot","truck","kayak","virus","testy","tepee","fully","zonal","metro","curry","grand","banjo","axion","bezel","occur","chain","nasal","gooey","filer","brace","allay","pubic","raven","plead","gnash","flaky","munch","dully","eking","thing","slink","hurry","theft","shorn","pygmy","ranch","wring","lemon","shore","mamma","froze","newer","style","moose","antic","drown","vegan","chess","guppy","union","lever","lorry","image","cabby","druid","exact","truth","dopey","spear","cried","chime","crony","stunk","timid","batch","gauge","rotor","crack","curve","latte","witch","bunch","repel","anvil","soapy","meter","broth","madly","dried","scene","known","magma","roost","woman","thong","punch","pasty","downy","knead","whirl","rapid","clang","anger","drive","goofy","email","music","stuff","bleep","rider","mecca","folio","setup","verso","quash","fauna","gummy","happy","newly","fussy","relic","guava","ratty","fudge","femur","chirp","forte","alibi","whine","petty","golly","plait","fleck","felon","gourd","brown","thrum","ficus","stash","decry","wiser","junta","visor","daunt","scree","impel","await","press","whose","turbo","stoop","speak","mangy","eying","inlet","crone","pulse","mossy","staid","hence","pinch","teddy","sully","snore","ripen","snowy","attic","going","leach","mouth","hound","clump","tonal","bigot","peril","piece","blame","haute","spied","undid","intro","basal","shine","gecko","rodeo","guard","steer","loamy","scamp","scram","manly","hello","vaunt","organ","feral","knock","extra","condo","adapt","willy","polka","rayon","skirt","faith","torso","match","mercy","tepid","sleek","riser","twixt","peace","flush","catty","login","eject","roger","rival","untie","refit","aorta","adult","judge","rower","artsy","rural","shave"]
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WordleDecoder
4
+ class Guess
5
+ def initialize(start_word, first_word_position, word_positions)
6
+ @start_word = start_word
7
+ @first_word_position = first_word_position
8
+ @word_positions = word_positions
9
+ end
10
+
11
+ def score
12
+ @score ||= words_with_scores.sum(&:last)
13
+ end
14
+
15
+ def word_scores
16
+ @word_scores ||= words_with_scores.map(&:last)
17
+ end
18
+
19
+ def words
20
+ @words ||= words_with_scores.map { |w, _s| w.to_s }
21
+ end
22
+
23
+ def words_with_scores
24
+ @words_with_scores ||= select_words_with_scores
25
+ end
26
+
27
+ def inspect
28
+ "<#{self.class.name} score: #{score}, word_scores: #{word_scores}, words: #{words}>"
29
+ end
30
+
31
+ private
32
+
33
+ #
34
+ # Greatly penalize words that have multiple of the same black letters
35
+ # Greatly penalize words that have the same black letters as any seen word
36
+ # Greatly penalize words that have the same yellow letter/index pair as any seen word
37
+ # Reward words that have yellow letters that match yellow letters in seen words, but in different positions
38
+ # Reward words that have yellow letters that match green letters in seen words
39
+ # Penalize words that have yellow letters that don't appear in seen words
40
+ # Penalize words that have green letters that don't appear in seen words
41
+ # Rewards words based on commonality
42
+ # Reward/penalize words based on line indexes and common letters
43
+ #
44
+ def select_words_with_scores
45
+ selected_words = [@start_word]
46
+ selected_word_scores = [@start_word.score]
47
+ seen_black_chars = @start_word.black_chars
48
+ seen_yellow_chars = @start_word.yellow_chars
49
+ seen_green_chars = @start_word.green_chars
50
+ seen_yellow_char_index_pairs = @start_word.yellow_char_index_pairs
51
+ @word_positions.each do |word_position|
52
+ potential_words = word_position.potential_words
53
+ potential_words = word_position.frequent_potential_words if potential_words.empty?
54
+ words_with_score_array = potential_words.map do |word|
55
+ word_score = word.score
56
+ next([word, word_score]) if word_score.negative?
57
+ next([word, -95]) unless (seen_black_chars & word.black_chars).empty?
58
+ next([word, -90]) unless (seen_yellow_char_index_pairs & word.yellow_char_index_pairs).empty?
59
+
60
+ word_score += (seen_yellow_chars & word.yellow_chars).count
61
+ word_score += (seen_green_chars & word.yellow_chars).count
62
+ word_score -= (word.yellow_chars - seen_yellow_chars - seen_green_chars).count
63
+ word_score -= (word.green_chars - seen_green_chars).count
64
+ [word, word_score]
65
+ end
66
+
67
+ best_word, best_score = words_with_score_array.max_by { _2 }
68
+ selected_words << best_word
69
+ selected_word_scores << best_score
70
+ seen_black_chars.concat(best_word.black_chars)
71
+ seen_yellow_chars.concat(best_word.yellow_chars)
72
+ seen_green_chars.concat(best_word.green_chars)
73
+ seen_yellow_char_index_pairs.concat(best_word.yellow_char_index_pairs)
74
+ end
75
+
76
+ selected_words.zip(selected_word_scores)
77
+ end
78
+
79
+ def normalize_confidence_score(word, score)
80
+ (word.confidence_score + score).clamp(word.confidence_score, 99)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WordleDecoder
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WordleDecoder
4
+ class Word
5
+ def initialize(word_str, word_position, commonality = nil)
6
+ @word_str = word_str
7
+ @word_position = word_position
8
+ @commonality = commonality
9
+ end
10
+
11
+ def score
12
+ @score ||= commonality_score + common_letter_score +
13
+ frequency_score - pentalty_score
14
+ end
15
+
16
+ def pentalty_score
17
+ guessed_same_letter_twice? ? 100 : 0
18
+ end
19
+
20
+ def confidence_score(guess_score)
21
+ position_score = @word_position.confidence_score
22
+ (position_score + guess_score).clamp(position_score, 99).round
23
+ end
24
+
25
+ def chars
26
+ @chars ||= @word_str.split("")
27
+ end
28
+
29
+ COMMON_LETTERS = %w[s e a o r i l t n].freeze
30
+ PENALTY_LETTERS_COUNT = 5
31
+
32
+ def common_letter_score
33
+ if @word_position.line_index <= 1
34
+ [(chars & COMMON_LETTERS).count, 3].min
35
+ else
36
+ letters_count = PENALTY_LETTERS_COUNT + @word_position.line_index
37
+ -(black_chars & COMMON_LETTERS.first(letters_count)).count
38
+ end
39
+ end
40
+
41
+ COMMONALITY_SCORES = { most: 2, less: 1, least: 0 }.freeze
42
+
43
+ def commonality_score
44
+ COMMONALITY_SCORES[@commonality] || 0
45
+ end
46
+
47
+ def frequency_score
48
+ @frequency_score ||= WordSearch.frequency_score(@word_str)
49
+ end
50
+
51
+ def green_chars
52
+ @green_chars ||= find_chars(@word_position.green_letter_positions)
53
+ end
54
+
55
+ def yellow_chars
56
+ @yellow_chars ||= find_chars(@word_position.yellow_letter_positions)
57
+ end
58
+
59
+ def yellow_char_index_pairs
60
+ @yellow_char_index_pairs ||= find_char_index_pairs(@word_position.yellow_letter_positions)
61
+ end
62
+
63
+ def black_chars
64
+ @black_chars ||= find_chars(@word_position.black_letter_positions)
65
+ end
66
+
67
+ def possible?
68
+ return true if yellow_chars.empty?
69
+
70
+ answer_chars = @word_position.answer_chars.dup
71
+ delete_green_chars!(answer_chars)
72
+
73
+ yellow_chars.all? do |yellow_char|
74
+ answer_char_index = answer_chars.index(yellow_char)
75
+ answer_chars.delete_at(answer_char_index) if answer_char_index
76
+ end
77
+ end
78
+
79
+ def to_s
80
+ @word_str
81
+ end
82
+
83
+ def to_terminal
84
+ @word_position.letter_positions.map do |letter_position|
85
+ char = @word_str[letter_position.index]
86
+ case letter_position.hint_char
87
+ when "g"
88
+ "{{green:#{char}}}"
89
+ when "y"
90
+ "{{yellow:#{char}}}"
91
+ else
92
+ char
93
+ end
94
+ end.join
95
+ end
96
+
97
+ private
98
+
99
+ def guessed_same_letter_twice?
100
+ black_chars.count != black_chars.uniq.count || !(black_chars & yellow_chars).empty?
101
+ end
102
+
103
+ def delete_green_chars!(answer_chars)
104
+ green_chars&.each do |green_char|
105
+ answer_char_index = answer_chars.index(green_char)
106
+ answer_chars.delete_at(answer_char_index) if answer_char_index
107
+ end
108
+ end
109
+
110
+ def find_chars(letter_positions)
111
+ return [] unless letter_positions
112
+
113
+ letter_positions.map { |lp| @word_str[lp.index] }
114
+ end
115
+
116
+ def find_char_index_pairs(letter_positions)
117
+ return [] unless letter_positions
118
+
119
+ letter_positions.map { |lp| [@word_str[lp.index], lp.index] }
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WordleDecoder
4
+ class WordPosition
5
+ EMOJI_HINT_CHARS = { "⬛" => "b",
6
+ "⬜" => "b",
7
+ "🟨" => "y",
8
+ "🟩" => "g" }.freeze
9
+
10
+ def initialize(hint_line, line_index, answer_chars)
11
+ @hint_chars = normalize_hint_chars(hint_line)
12
+ @answer_chars = answer_chars
13
+ @line_index = line_index
14
+ @letter_positions = initialize_letter_positions(@hint_chars, @answer_chars)
15
+ end
16
+
17
+ attr_reader :hint_chars,
18
+ :answer_chars,
19
+ :line_index,
20
+ :letter_positions
21
+
22
+ def potential_words
23
+ @potential_words ||= initialize_potential_words
24
+ end
25
+
26
+ def frequent_potential_words
27
+ @frequent_potential_words ||= find_10_frequent_potential_words
28
+ end
29
+
30
+ BASE_INCONFIDENCE = 0.05
31
+
32
+ def confidence_score
33
+ return 1 if potential_words.empty?
34
+
35
+ score = (100 * (1.0 / potential_words.count.to_f))
36
+ (score - (score * BASE_INCONFIDENCE)).round
37
+ end
38
+
39
+ def green_letter_positions
40
+ @green_letter_positions ||= select_letter_positions_by_hint("g")
41
+ end
42
+
43
+ def yellow_letter_positions
44
+ @yellow_letter_positions ||= select_letter_positions_by_hint("y")
45
+ end
46
+
47
+ def black_letter_positions
48
+ @black_letter_positions ||= select_letter_positions_by_hint("b")
49
+ end
50
+
51
+ private
52
+
53
+ def initialize_potential_words
54
+ potential_words = []
55
+ WordSearch::COMMONALITY_OPTIONS.each do |commonality|
56
+ word_strings = compute_words_from_hints(commonality)
57
+ next if word_strings.empty?
58
+
59
+ word_strings = remove_impossible_words(word_strings, commonality)
60
+ next if word_strings.empty?
61
+
62
+ new_words = word_strings.map! { |str| Word.new(str, self, commonality) }
63
+ new_words.select!(&:possible?)
64
+ potential_words.concat(new_words)
65
+ end
66
+ potential_words
67
+ end
68
+
69
+ def compute_words_from_hints(commonality)
70
+ words = nil
71
+ [green_letter_positions, yellow_letter_positions].each do |letters|
72
+ words = filter_by_words(words, letters, commonality) if letters
73
+ end
74
+ words || []
75
+ end
76
+
77
+ def filter_by_words(words, letters, commonality)
78
+ letters.each do |letter|
79
+ if words
80
+ words &= letter.potential_words(commonality)
81
+ else
82
+ words = letter.potential_words(commonality)
83
+ end
84
+ end
85
+ words
86
+ end
87
+
88
+ def remove_impossible_words(words, commonality)
89
+ black_letter_positions&.each do |letter|
90
+ words -= letter.impossible_words(commonality)
91
+ end
92
+ words
93
+ end
94
+
95
+ def select_letter_positions_by_hint(hint_char)
96
+ @letter_positions.select { |lg| lg.hint_char == hint_char }
97
+ end
98
+
99
+ def find_10_frequent_potential_words
100
+ word_strings = WordSearch.most_frequent_words_without_chars(@answer_chars, 10)
101
+ word_strings.map { |str| Word.new(str, self) }
102
+ end
103
+
104
+ def normalize_hint_chars(hint_line)
105
+ hint_line.each_char.map { |c| EMOJI_HINT_CHARS[c] || c }
106
+ end
107
+
108
+ def initialize_letter_positions(hint_chars, answer_chars)
109
+ hint_chars.each_with_index.map do |hint_char, index|
110
+ LetterPosition.new(index, hint_char, hint_chars, answer_chars)
111
+ end
112
+ end
113
+
114
+ class LetterPosition
115
+ def initialize(index, hint_char, hint_chars, answer_chars)
116
+ @index = index
117
+ @hint_char = hint_char
118
+ @hint_chars = hint_chars
119
+ @answer_chars = answer_chars
120
+ @answer_char = @answer_chars[index]
121
+ end
122
+
123
+ attr_reader :hint_char,
124
+ :answer_char,
125
+ :index
126
+
127
+ def potential_words(commonality)
128
+ case hint_char
129
+ when "g"
130
+ WordSearch.char_at_index(@answer_char, @index, commonality)
131
+ when "y"
132
+ if @hint_chars.count("g") == 3
133
+ must_be_char_index = @hint_chars.index.with_index { |h, i| h != "g" && i != @index }
134
+ must_be_char = @answer_chars[must_be_char_index]
135
+ WordSearch.char_at_index(must_be_char, @index, commonality)
136
+ else
137
+ # TODO: if yellow char is in the word as green already, and there's only one isntance of it
138
+ # in final word, can assume it's not that char.
139
+ chars = @answer_chars - [@answer_char]
140
+ WordSearch.chars_at_index(chars, @index, commonality)
141
+ end
142
+ end
143
+ end
144
+
145
+ def impossible_words(commonality)
146
+ WordSearch.chars_at_index(@answer_chars, @index, commonality)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ class WordleDecoder
6
+ class WordSearch
7
+ COMMONALITY_OPTIONS = %i[most less least].freeze
8
+
9
+ class << self
10
+ def char_at_index(char, index, commonality)
11
+ case commonality
12
+ when :most
13
+ most_common_letter_to_words_arrays[index][char]
14
+ when :less
15
+ less_common_letter_to_words_arrays[index][char]
16
+ when :least
17
+ least_common_letter_to_words_arrays[index][char]
18
+ end
19
+ end
20
+
21
+ def chars_at_index(chars, index, commonality)
22
+ chars.uniq.flat_map { |c| char_at_index(c, index, commonality) }
23
+ end
24
+
25
+ def frequency_score(word)
26
+ words_to_frequency_score_hash[word]
27
+ end
28
+
29
+ def words_to_frequency_score_hash
30
+ @words_to_frequency_score_hash ||= load_words_to_frequency_score_hash
31
+ end
32
+
33
+ def most_frequent_words_without_chars(without_chars, limit)
34
+ regex = /#{without_chars.join("|")}/
35
+ words = []
36
+ words_to_frequency_score_hash.each_key.reverse_each do |str|
37
+ words << str unless str.match?(regex)
38
+
39
+ return(words) if words.count == limit
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def load_words_to_frequency_score_hash
46
+ JSON.parse(file_path("words_to_frequency_score.json"))
47
+ end
48
+
49
+ def most_common_letter_to_words_arrays
50
+ @most_common_letter_to_words_arrays ||= build_letter_to_words_hashes("most_common_words.txt")
51
+ end
52
+
53
+ def less_common_letter_to_words_arrays
54
+ @less_common_letter_to_words_arrays ||= build_letter_to_words_hashes("less_common_words.txt")
55
+ end
56
+
57
+ def least_common_letter_to_words_arrays
58
+ @least_common_letter_to_words_arrays ||= build_letter_to_words_hashes("least_common_words.txt")
59
+ end
60
+
61
+ def build_letter_to_words_hashes(words_file_name)
62
+ words = load_wordle_words(words_file_name)
63
+ Array.new(5) { |index| words.group_by { |word| word[index] } }
64
+ end
65
+
66
+ def load_wordle_words(file_name)
67
+ file_path(file_name).split("\n")
68
+ end
69
+
70
+ def file_path(file_name)
71
+ file_path = File.join(File.dirname(__FILE__), "..", file_name)
72
+ File.read(file_path)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WordleDecoder
4
+ class WordleShare
5
+ ANSWER_LINES = ["🟩🟩🟩🟩🟩", "ggggg"].freeze
6
+
7
+ def self.final_line?(input_lines)
8
+ ANSWER_LINES.any? { input_lines.include?(_1) }
9
+ end
10
+
11
+ def self.wordle_answers
12
+ @wordle_answers ||= load_worldle_ansers
13
+ end
14
+
15
+ def self.load_worldle_ansers
16
+ file_path = File.join(File.dirname(__FILE__), "..", "wordle_answers.json")
17
+ JSON.parse File.read(file_path)
18
+ end
19
+
20
+ def initialize(input, answer_input = nil)
21
+ @input = input
22
+ self.answer_input = answer_input
23
+ end
24
+
25
+ attr_reader :input,
26
+ :answer_input
27
+
28
+ attr_accessor :answer
29
+
30
+ def answer_input=(val)
31
+ @answer_input = val
32
+ @answer = normalize_answer_input(val)
33
+ end
34
+
35
+ GAME_DAY_REGEX = /wordle\s(\d+)\s/i.freeze
36
+
37
+ def find_answer
38
+ title_line = input_lines.detect { |line| line.match?(GAME_DAY_REGEX) }
39
+ game_day = title_line.match(GAME_DAY_REGEX).captures.first&.to_i
40
+ self.answer = self.class.wordle_answers[game_day]
41
+ end
42
+
43
+ def answer_chars
44
+ @answer_chars ||= answer&.strip&.split("")
45
+ end
46
+
47
+ def hint_lines
48
+ @hint_lines ||= parse_hint_lines!
49
+ end
50
+
51
+ def wordle_lines
52
+ @wordle_lines ||= parse_wordle_lines!
53
+ end
54
+
55
+ def input_lines
56
+ @input_lines ||= normalize_input_lines(parse_input_lines!)
57
+ end
58
+
59
+ def to_terminal
60
+ "{{blue:>}} #{wordle_lines.join("\n ")}"
61
+ end
62
+
63
+ def decoder
64
+ @decoder ||= WordleDecoder.new(self)
65
+ end
66
+
67
+ def inspect
68
+ "<#{self.class.name} input: #{input}, answer_input: #{answer_input}" \
69
+ " answer_chars: #{answer_chars.inspect} hint_lines: #{hint_lines.inspect}>"
70
+ end
71
+
72
+ private
73
+
74
+ def parse_hint_lines!
75
+ hint_lines = wordle_lines.dup
76
+ hint_lines.pop if ANSWER_LINES.include?(hint_lines.last)
77
+ hint_lines
78
+ end
79
+
80
+ VALID_HINT_CHARS = WordPosition::EMOJI_HINT_CHARS.to_a.flatten.uniq
81
+
82
+ def parse_wordle_lines!
83
+ input_lines.select do |line|
84
+ line.each_char.all? { |c| VALID_HINT_CHARS.include?(c) }
85
+ end
86
+ end
87
+
88
+ def parse_input_lines!
89
+ case input
90
+ when String
91
+ convert_input_string_to_input_lines
92
+ when Array
93
+ input
94
+ else
95
+ raise Error, "Input must be a String or Array"
96
+ end
97
+ end
98
+
99
+ def normalize_input_lines(input_lines)
100
+ input_lines.filter_map do |input_line|
101
+ line = input_line.strip
102
+ line unless line.empty?
103
+ end
104
+ end
105
+
106
+ def convert_input_string_to_input_lines
107
+ hint_input = input.downcase
108
+ if hint_input.include?("\n")
109
+ hint_input.split("\n")
110
+ else
111
+ hint_input.chars.each_slice(5).map(&:join)
112
+ end
113
+ end
114
+
115
+ VALID_ANSWER_CHARS = ("a".."z").freeze
116
+
117
+ def normalize_answer_input(val)
118
+ val = val&.strip&.downcase
119
+ val if val && val.length == 5 && val.each_char.all? { |c| VALID_ANSWER_CHARS.include?(c) }
120
+ end
121
+ end
122
+ end