# File: WordMorph.pm # # Purpose: Word morph game. Solve a path between two words by changing one # letter at a time. love > shot = love > lose > lost > loot > soot > shot. # SPDX-FileCopyrightText: 2022-2023 Pragmatic Software # SPDX-License-Identifier: MIT package PBot::Plugin::WordMorph; use parent 'PBot::Plugin::Base'; use PBot::Imports; use Storable; use Text::Levenshtein::XS 'distance'; sub initialize($self, %conf) { $self->{pbot}->{commands}->add( name => 'wordmorph', help => 'Word Morph game! Solve a path between two words by changing one letter at a time: love > shot = love > lose > lost > loot > soot > shot.', subref => sub { $self->wordmorph(@_) }, ); $self->{db_path} = $self->{pbot}->{registry}->get_value('general', 'data_dir') . '/wordmorph.db'; $self->{db} = eval { $self->load_db() } or $self->{pbot}->{logger}->log($@); } sub unload($self) { $self->{pbot}->{commands}->remove('wordmorph'); } use constant { USAGE => 'Usage: wordmorph start [steps to solve [word length]] | custom ( | ) | solve | hint [from direction] | check | neighbors | search | show | giveup', NO_MORPH_AVAILABLE => "There is no word morph available. Use `wordmorph start [steps to solve [word length]]` to create one.", DB_UNAVAILABLE => "Word morph database not available.", LEFT => 0, RIGHT => 1, MIN_STEPS => 2, MAX_STEPS => 8, DEFAULT_STEPS => 3, MIN_WORD_LENGTH => 3, MAX_WORD_LENGTH => 7, }; sub wordmorph($self, $context) { my @args = $self->{pbot}->{interpreter}->split_line($context->{arguments}); my $command = shift @args; if (not length $command) { return USAGE; } my $channel = $context->{from}; given ($command) { when ('neighbors') { if (!@args || @args > 1) { return 'Usage: wordmorph neighbors ; list the neighbors of a given word'; } return DB_UNAVAILABLE if not $self->{db}; if (my $err = $self->validate_word($args[0], MIN_WORD_LENGTH, MAX_WORD_LENGTH)) { return $err; } my @neighbors = @{$self->{db}->{length $args[0]}->{$args[0]}}; my $count = @neighbors; return "`$args[0]` has $count neighbor" . ($count != 1 ? 's' : '') . ": " . join(', ', sort @neighbors); } when ('check') { if (!@args || @args > 1) { return 'Usage: wordmorph check ; check if a word exists in the Word Morph database'; } return DB_UNAVAILABLE if not $self->{db}; if (my $err = $self->validate_word($args[0], MIN_WORD_LENGTH, MAX_WORD_LENGTH)) { return $err; } return "Yes, `$args[0]` is a word I know."; } when ('hint') { if (not defined $self->{$channel}->{morph}) { return NO_MORPH_AVAILABLE; } if (@args > 1) { return 'Usage: wordmorph hint [from direction]; from direction can be `left` or `right`'; } my $direction; if (@args == 0) { $direction = LEFT; } elsif ($args[0] eq 'left' || $args[0] eq 'l') { $direction = LEFT; } elsif ($args[0] eq 'right' || $args[0] eq 'r') { $direction = RIGHT; } else { return "Unknown direction `$args[0]`; usage: wordmorph hint [from direction]; from direction can be `left` or `right`"; } my $morph = $self->{$channel}->{morph}; my $end = $#$morph; if ($direction == LEFT) { $self->{$channel}->{hintL}++; if ($self->{$channel}->{hintL} > $end) { $self->{$channel}->{hintL} = $end; } } else { $self->{$channel}->{hintR}--; if ($self->{$channel}->{hintR} < 0) { $self->{$channel}->{hintR} = 0; } } my @hints; $hints[0] = $morph->[0]; $hints[$end] = $morph->[$end]; for (my $i = 1; $i < $self->{$channel}->{hintL}; $i++) { my $word1 = $morph->[$i - 1]; my $word2 = $morph->[$i]; $hints[$i] = form_hint($word1, $word2); } my $blank_hint = '_' x length $morph->[0]; for (my $i = $self->{$channel}->{hintL}; $i < $self->{$channel}->{hintR} + 1; $i++) { $hints[$i] = $blank_hint; } for (my $i = $end - 1; $i > $self->{$channel}->{hintR}; $i--) { my $word1 = $morph->[$i]; my $word2 = $morph->[$i + 1]; $hints[$i] = form_hint($word1, $word2); } return "Hint: " . join(' > ', @hints); } when ('show') { if (not defined $self->{$channel}->{morph}) { return NO_MORPH_AVAILABLE; } return "Current word morph: " . $self->show_morph_with_blanks($channel) . " (Fill in the blanks)"; } when ('giveup') { if (not defined $self->{$channel}->{morph}) { return NO_MORPH_AVAILABLE; } my $solution = join ' > ', @{$self->{$channel}->{morph}}; $self->{$channel}->{morph} = undef; return "The solution was $solution. Better luck next time."; } when ('start') { if (@args > 2) { return "Invalid arguments; Usage: wordmorph start [steps to solve [word length]]"; } my $steps = DEFAULT_STEPS; my $length = undef; if (defined $args[0]) { if ($args[0] !~ m/^[0-9]+$/ || $args[0] < MIN_STEPS || $args[0] > MAX_STEPS) { return "Invalid number of steps `$args[0]`; must be integer >= ".MIN_STEPS." and <= ".MAX_STEPS."."; } $steps = $args[0]; } if (defined $args[1]) { if ($args[1] !~ m/^[0-9]+$/ || $args[1] < MIN_WORD_LENGTH || $args[1] > MAX_WORD_LENGTH) { return "Invalid word length `$args[1]`; must be integer >= ".MIN_WORD_LENGTH." and <= ".MAX_WORD_LENGTH."."; } $length = $args[1]; } return DB_UNAVAILABLE if not $self->{db}; my $attempts = 1000; my $morph; while (--$attempts > 0) { $morph = eval { $self->make_morph_by_steps($self->{db}, $steps + 2, $length) }; if (my $err = $@) { next if $err eq "Too many attempts\n"; return $err; } last if @$morph; } if (not @$morph) { return "Failed to create Word Morph with given parameters, in reasonable time. Try again."; } $self->set_up_new_morph($morph, $channel); return "New word morph: " . $self->show_morph_with_blanks($channel) . " (Fill in the blanks)"; } when ('custom') { return "Usage: wordmorph custom ( | )" if @args != 2; return DB_UNAVAILABLE if not $self->{db}; if (my $err = $self->validate_word($args[0], MIN_WORD_LENGTH, MAX_WORD_LENGTH)) { return $err; } my $morph; if ($args[1] =~ /^\d+$/) { my $steps = DEFAULT_STEPS; my $length = length $args[0]; if ($args[1] < MIN_STEPS || $args[1] > MAX_STEPS) { return "Invalid number of steps `$args[1]`; must be integer >= ".MIN_STEPS." and <= ".MAX_STEPS."."; } $steps = $args[1]; return DB_UNAVAILABLE if not $self->{db}; my $attempts = 100; while (--$attempts > 0) { $morph = eval { $self->make_morph_by_steps($self->{db}, $steps + 2, $length, $args[0]) }; if (my $err = $@) { next if $err eq "Too many attempts\n"; return $err; } last if @$morph; } if (!$morph || !@$morph) { return "Failed to create Word Morph with given parameters, in reasonable time. Try again."; } } else { if (my $err = $self->validate_word($args[1], MIN_WORD_LENGTH, MAX_WORD_LENGTH)) { return $err; } $morph = eval { makemorph($self->{db}, $args[0], $args[1]) } or return $@; return "Failed to find a path between `$args[0]` and `$args[1]`." if !$morph || !@$morph; } $self->set_up_new_morph($morph, $channel); return "New word morph: " . $self->show_morph_with_blanks($channel) . " (Fill in the blanks)"; } when ('search') { if (not @args) { return "Usage: wordmorph search "; } return DB_UNAVAILABLE if not $self->{db}; my @words; eval { foreach my $length (keys $self->{db}->%*) { foreach my $word (keys $self->{db}->{$length}->%*) { if ($word =~ m/$args[0]/) { push @words, $word; } } } }; if (my $except = $@) { $except =~ s/ at \/home.*$//; return "Error: $except"; } if (not @words) { return "No matching words found."; } return scalar @words . (@words == 1 ? ' word' : ' words') . ': ' . join(' ', @words); } when ('solve') { if (not @args) { return "Usage: wordmorph solve "; } if (not defined $self->{$channel}->{morph}) { return NO_MORPH_AVAILABLE; } return DB_UNAVAILABLE if not $self->{db}; my @solution = grep { length > 0 } split /\W/, join(' ', @args); if ($solution[0] ne $self->{$channel}->{word1}) { unshift @solution, $self->{$channel}->{word1}; } if ($solution[$#solution] ne $self->{$channel}->{word2}) { push @solution, $self->{$channel}->{word2}; } my $last_word = $solution[0]; if (not exists $self->{db}->{length $last_word}->{$last_word}) { return "I do not know this word `$last_word`."; } my $length = length $last_word; for (my $i = 1; $i < @solution; $i++) { my $word = $solution[$i]; if (not exists $self->{db}->{length $word}->{$word}) { return "I do not know this word `$word`."; } if (length($word) != $length || distance($word, $last_word) != 1) { return "Wrong. `$word` does not follow from `$last_word`."; } $last_word = $word; } my $expected_steps = @{$self->{$channel}->{morph}}; if (@solution > $expected_steps) { return "Almost! " . join(' > ', @solution) . " is too long."; } if (@solution == $expected_steps) { return "Correct! " . join(' > ', @solution); } # this should never happen ... but just in case return "Correct! " . join(' > ', @solution) . " is shorter than the expected solution. Congratulations!"; } default { return "Unknown command `$command`; " . USAGE; } } } sub load_db($self) { if (not -e $self->{db_path}) { die "Word morph database not available; run `/misc/wordmorph/wordmorph-mkdb` to create it.\n"; } return retrieve($self->{db_path}); } sub show_morph_with_blanks($self, $channel) { my @middle; for (1 .. @{$self->{$channel}->{morph}} - 2) { push @middle, '_' x length $self->{$channel}->{word1}; } return "$self->{$channel}->{word1} > " . join(' > ', @middle) . " > $self->{$channel}->{word2}"; } sub set_up_new_morph($self, $morph, $channel) { $self->{$channel}->{morph} = $morph; $self->{$channel}->{word1} = $morph->[0]; $self->{$channel}->{word2} = $morph->[$#$morph]; $self->{$channel}->{hintL} = 1; $self->{$channel}->{hintR} = $#$morph - 1; } sub form_hint($word1, $word2) { my $hint = ''; for (0 .. length $word1) { if (substr($word1, $_, 1) eq substr($word2, $_, 1)) { $hint .= substr($word1, $_, 1); } else { $hint .= "?"; } } return $hint; } sub validate_word($self, $word, $min, $max) { my $len = length $word; if ($len < $min) { return "`$word` is too short; minimum word length is $min."; } elsif ($len > $max) { return "`$word` is too long, maximum word length is $max."; } if (not exists $self->{db}->{$len}->{$word}) { return "I do not know this word `$word`."; } return undef; } sub compare_suffix($word1, $word2) { my $length = 0; for (my $i = length($word1) - 1; $i >= 0; --$i) { if (substr($word1, $i, 1) eq substr($word2, $i, 1)) { $length++; } else { last; } } return $length; } sub make_morph_by_steps($self, $db, $steps, $length, $word1 = undef) { $length //= int(rand(3)) + 5; my @words = keys %{$db->{$length}}; my $word = $word1 // $words[rand $#words]; my $morph = []; push @$morph, $word; my $attempts = 100; while (--$attempts > 0) { my @list = @{$db->{$length}->{$word}}; $word = $list[rand $#list]; if (grep { $_ eq $word } @$morph) { next; } my $try = eval { my $left = $morph->[0]; die if compare_suffix($left, $word) >= 2; [transform($left, $word, $db->{length $left})] } or next; $morph = []; my $curr_steps = $steps; foreach my $word (@$try) { push @$morph, $word; if (--$curr_steps <= 0) { return $morph; } } } die "Too many attempts\n"; } # the following subs are based on https://www.perlmonks.org/?node_id=558123 sub makemorph($db, $left, $right) { die "The length of given words are not equal.\n" if length($left) != length($right); my $list = $db->{length $left}; my $morph = eval { [transform(lc $left, lc $right, $list)] } or die $@; return $morph; } sub transform($left, $right, $list) { my (@left, %left, @right, %right); # @left and @right- arrays containing word relation trees: ([foo], [0, foe], [0, fou], [0, 1, fie] ...) # %left and %right - indices containing word offsets in arrays @left and @right $left[0] = [$left]; $right[0] = [$right]; $left{$left} = 0; $right{$right} = 0; my $leftstart = 0; my $rightstart = 0; my @path; my (%leftstarts, %rightstarts); die "I do not know this word `$left`.\n" if not exists $list->{$left}; die "I do not know this word `$right`.\n" if not exists $list->{$right}; SEARCH: for (;;) { my @left_ids = $leftstart..$#left; # choose array of indices of new words $leftstart = $#left; die "Cannot find a path from `$left` to `$right`.\n" if $leftstarts{$leftstart}++ > 2; # finish search if the path could not be found for my $id (@left_ids) { # come through all new words my @prefix = @{$left[$id]}; my $searched = pop @prefix; push @prefix, $id; foreach my $word (@{$list->{$searched}}) { next if $left{$word}; # skip words which are already in the tree push @left, [@prefix, $word]; $left{$word} = $#left; # add new word to array and index if ( defined(my $r_id = $right{$word}) ) { # and check if the word appears in right index. if yes... my @end = reverse(print_rel($r_id, \@right)); shift @end; @path = (print_rel($#left, \@left), @end); # build the path between the words last SEARCH; # and finish the search } } } my @right_ids = $rightstart..$#right; # all the same :) the tree is build from both ends to speed up the process $rightstart = $#right; die "Cannot find a path from `$left` to `$right`.\n" if $rightstarts{$rightstart}++ > 2; # finish search if the path could not be found for my $id (@right_ids) { # build right relational table my @prefix = @{$right[$id]}; my $searched = pop @prefix; push @prefix, $id; foreach my $word (@{$list->{$searched}}) { next if $right{$word}; push @right, [@prefix, $word]; $right{$word} = $#right; if ( defined(my $l_id = $left{$word}) ) { my @end = reverse print_rel($#right, \@right); shift @end; @path = (print_rel($l_id, \@left), @end); last SEARCH; } } } } return @path; } sub print_rel($id, $ary) { my @rel = @{$ary->[$id]}; my @line; push @line, (pop @rel); foreach my $ref_id (reverse @rel) { unshift @line, $ary->[$ref_id]->[-1]; } return wantarray ? @line : join "\n", @line, ""; } 1;