root/veekun/trunk/lib/Vee/Controller/Dex/Search.pm @ 445

Revision 445, 20.3 KB (checked in by eevee, 18 months ago)

Fixed dexsuggest boxes that are supposed to only show moves. (#320)

Line 
1package Vee::Controller::Dex::Search;
2
3use strict;
4use warnings;
5use base 'Catalyst::Controller';
6
7use Vee::Form;
8use Vee::Dex;
9
10use List::MoreUtils qw/uniq/;
11
12__PACKAGE__->config->{namespace} = 'dex';
13
14=head1 NAME
15
16Vee::Controller::Dex::Search - Pokedex search Controller
17
18=head1 SYNOPSIS
19
20See L<Vee>
21
22=head1 DESCRIPTION
23
24Catalyst Controller for searching for thingers in the Pokedex.
25
26=cut
27
28=head1 METHODS
29
30=cut
31
32=head2 pokemon_search
33
34Search for pokemachus.
35
36=cut
37
38my %endpoints = (
39    lb => { english => 'Minimum', operator => '>=', polarity => -1 },
40    ub => { english => 'Maximum', operator => '<=', polarity =>  1 },
41);   
42
43# Pokemon search form
44our $pokemon_search_fields = {
45    name        => { type => 'text', size => 15, title => 'Enter a name or part of a name' },
46    habitat     => { type => 'select', options => ['any', 'cave', 'forest', 'grassland', 'mountain', 'rare', 'rough terrain', 'sea', 'urban', 'water\'s edge'] },
47    generation  => { type => 'checkbox', options => [ map { [ $_ => $Generations[$_]{games} ] } 0 .. $#Generations ] },
48    basedex     => { type => 'checkbox', options => [ map { [ $_ => $Generations[$_]{games} ] } 1 .. $#Generations ] },
49    breed       => { type => 'select', options => [ [ 0 => 'n/a' ], map { [ $_ => ($_ ? "$_: " : '') . $BreedingGroups[$_] ] } 1 .. $#BreedingGroups ], count => 2 },
50    breed_mode  => { type => 'select', options => [ [ and => 'exactly' ], [ or => 'either of' ] ], default => 'or' },
51    gender_rate => { type => 'select', options => [ [ any => 'anything' ], [ 255 => 'no gender' ], [ not255 => 'any gender' ], map { [ $_ => lc gender_text($_) ] } qw/0 31 63 127 191 254/ ] },
52    ability     => { type => 'text', size => 20, title => 'Enter the name or number of an ability' },
53    color       => { type => 'select', options => [qw/any black blue brown gray green pink purple red white yellow/], title => 'I have no explanation for why this is here' },
54    type_mode   => { type => 'radio', options => [[ ignore => 'Ignore types' ], [ any => 'Find Pok&eacute;mon with any type that matches one selected' ], [ all => 'Find Pok&eacute;mon with exactly the selected type combination' ]], default => 'ignore', title => 'Select how types must be matched' },
55    type        => { type => 'checkbox', options => [ @TypeNames ], title => 'Select types for the Pokemon to match' },
56    move        => { type => 'text', count => 4, class => 'js-dexsuggest js-dexsuggest-move', title => 'Enter moves the Pokemon must have' },
57    move_method => { type => 'checkbox', options => { level => 'Level up', machine => 'TM or HM', egg => 'Egg', tutor => 'Move Tutor' }, title => 'Select ways the Pokemon may learn the move' },
58    move_version=> { type => 'checkbox', options => [ map { [ $_ => $Icons{$_} ] } qw/rb y gs c rusa frlg e dp/ ], title => 'Select ways the Pokemon may learn the move' },
59    evo_stage    => { type => 'checkbox', options => ['base', 'final'] },
60
61    view        => { type => 'select', options => [[ list => 'boring list' ], [ icons => "small compact icons" ], [ sprites => "sprites" ], [ table => "detailed table" ]], default => 'table', title => 'Select how you want the results displayed' },
62    sort        => { type => 'select', options => [[ id => 'National ID' ], [ name => 'Name' ], [ height => 'Height' ], [ weight => 'Weight' ], ( map { [ $StatColumns[$_] => $StatNames[$_] ] } 0 .. $#StatColumns ), [ stat_avg => 'Average stats' ] ], default => 'name', title => 'Select how you want the results ordered' },
63    sort_desc   => { type => 'checkbox', title => 'Check this for descending order' },
64};
65
66for my $stat (0 .. $#StatColumns) {
67    for my $endpoint (keys %endpoints) {
68        $pokemon_search_fields->{ $StatColumns[$stat] . '_' . $endpoint } = { type => 'text', size => 4, maxlength => 3, title => "\u$endpoints{$endpoint}{english} $StatNames[$stat]" };
69    }
70}
71for my $stat (0 .. $#StatColumns) {
72    for my $endpoint (keys %endpoints) {
73        $pokemon_search_fields->{ $StatColumns[$stat] . '_effort_' . $endpoint } = { type => 'text', size => 4, maxlength => 3, title => "\u$endpoints{$endpoint}{english} $StatNames[$stat] effort" };
74    }
75}
76for my $nonstat (qw/height weight/) {
77    for my $endpoint (keys %endpoints) {
78        $pokemon_search_fields->{ $nonstat . '_' . $endpoint } = { type => 'text', size => 4, maxlength => 8, title => "\u$endpoints{$endpoint}{english} $nonstat" };
79    }
80}
81
82my %view_sizes = (
83    icons   => 250,
84    sprites => 100,
85    default => 50,
86);
87
88sub pokemon_search : Path('pokemon/search') : Args(0) {
89    my ($self, $c) = @_;
90    my $s = $c->stash;
91    my $p = $c->req->params;
92    my $skip = $p->{skip} || 0;
93    $s->{before} = {%{ $c->req->params }};
94   
95    my $form = $s->{form} = Vee::Form->new(
96        id => 'pokemon_search',
97        fields => $pokemon_search_fields,
98        params => $c->req->params,
99    );
100
101    my $PAGESIZE = $s->{PAGESIZE} = $view_sizes{ $p->{view} } || $view_sizes{default} || 10;
102
103    if ($form->submitted) {
104        my %criteria;
105        $criteria{'-and'} = [ { 'me.id' => { '<=', $Generations[-1]{maxid} } } ];
106        my (%joins, %clauses);
107        $clauses{columns} = ['me.id'] unless $p->{view} eq 'table';
108       
109        # BASIC
110        if ($p->{name}) {
111            my $name = $p->{name};
112            if ($name =~ /[*?]/) {
113                $name =~ tr/*?/%_/;
114                $criteria{'me.name'} = { like => $name };
115            } else {
116                $criteria{'me.name'} = { like => "%$name%" };
117            }
118        }
119        if ($p->{habitat} ne 'any') { $criteria{'me.habitat'} = $p->{habitat} }
120        if ($p->{color} and $p->{color} ne 'any') { $criteria{'me.color'} = $p->{color} };
121        if ($p->{ability}) {
122            my $ability = $c->model('DBIC::Abilities')->single( [ id => $p->{ability}, name => $p->{ability} ], { columns => ['id'] } );
123            if ($ability) {
124                $joins{pokemon_abilities} = 1;
125                $criteria{'pokemon_abilities.ability_id'} = $ability->id;
126            }
127        }
128       
129        # GENERATIONS
130        if (defined $p->{generation}) {
131            my $gen_ref = [];
132            for my $gen (Vee::Utils::array($p->{generation})) {
133                push @$gen_ref, { '-between' => [ ($gen > 0 ? $Generations[$gen - 1]{maxid} : 0) + 1, $Generations[$gen]{maxid} ] };
134            }
135            push @{$criteria{'-and'}}, { 'me.id' => $gen_ref };
136        }
137        if (defined $p->{basedex}) {
138            for my $gen (Vee::Utils::array($p->{basedex})) {
139                $criteria{ 'me.id_' . lc $Generations[$gen]{region} } = { '!=' => 0 };
140            }
141        }
142
143        # BREEDING AND STUFF
144        if (defined $p->{gender_rate} and $p->{gender_rate} ne 'any') {
145            if ($p->{gender_rate} eq 'not255') {
146                $criteria{'me.gender_rate'} = { '!=' => 255 };
147            } else {
148                $criteria{'me.gender_rate'} = $p->{gender_rate};
149            }
150        }
151        my @breeds = uniq grep { $_ } Vee::Utils::array($p->{breed});
152        if (@breeds) {
153            $joins{breeds} = 1;
154            $criteria{'breeds.breed'} = \@breeds;
155            if ($p->{breed_mode} eq 'and') {
156                $clauses{having}{'COUNT(DISTINCT breeds.pokemon_id, breeds.breed)'} = scalar @breeds;
157
158                if (@breeds == 1) {
159                    # special hackery here to say one and ONLY one.
160                    # I have to use SUM() because only grouped columns are recognized in HAVING.
161                    delete $criteria{'breeds.breed'};
162                    $clauses{having}{'SUM(breeds.breed)'} = $breeds[0];
163                }
164            }
165
166            # TODO: do..  something..  if someone sticks in 3+?
167        }
168
169        # TYPES       
170        if ($p->{type_mode} eq 'any') {
171            push @{$criteria{'-and'}}, [
172                '-or',
173                { 'me.type1' => $p->{type}, 'me.type2' => $p->{type} },
174            ];
175        } elsif ($p->{type_mode} eq 'all') {
176            if (not ref $p->{type}) {
177                push @{$criteria{'-and'}}, { 'me.type1' => $p->{type}, 'me.type2' => undef };
178            } elsif (scalar @{ $p->{type} } == 2) {
179                push @{$criteria{'-and'}}, { -or => [
180                    { 'me.type1' => $p->{type}[0], 'me.type2' => $p->{type}[1] },
181                    { 'me.type1' => $p->{type}[1], 'me.type2' => $p->{type}[0] },
182                ] };
183            } else {
184                # TODO: this should probably be better and throw an error of some sort
185                push @{$criteria{'-and'}}, \ 0;
186            }
187        }
188
189        # MOVES
190        if (ref $p->{move} eq 'ARRAY') {
191            my @moveids;
192            for my $move (@{ $p->{move} }) {
193                # XXX: show some message if a move is invalid?
194                my $move_id = get_move($move);
195                push @moveids, $move_id if defined $move_id;
196            }
197            if (@moveids) {
198                $joins{pokemon_moves} = 1;
199                $criteria{'pokemon_moves.move_id'} = \@moveids;
200                $clauses{having}{'COUNT(DISTINCT pokemon_moves.pokemon_id, pokemon_moves.move_id)'} = scalar @moveids;
201
202                if ($p->{move_method}) {
203                    $criteria{'pokemon_moves.method'} = $p->{move_method};
204                }
205                if ($p->{move_version}) {
206                    push @{$criteria{'-and'}}, { '-or', [ map { \ "FIND_IN_SET('$_', pokemon_moves.versions)" } Vee::Utils::array($p->{move_version}) ] };
207                }
208            }
209        }
210       
211        # NUMBERS
212        for my $numbar (@StatColumns, qw/height weight/) {
213            for my $endpoint (keys %endpoints) {
214                my $value = $p->{$numbar . '_' . $endpoint};
215                if ($numbar eq 'height') {
216                    $value = parse_height($value, $endpoints{$endpoint}{polarity});
217                } elsif ($numbar eq 'weight') {
218                    $value = parse_weight($value, $endpoints{$endpoint}{polarity});
219                }
220                # TODO: umm, make this throw an error for invalid height/weight?
221                next unless Vee::Utils::isnum($value) and $value > 0;
222                $criteria{"me.$numbar"}{ $endpoints{$endpoint}{operator} } = $value;
223            }
224        }
225        for my $stat (0 .. $#StatColumns) {
226            for my $endpoint (keys %endpoints) {
227                my $value = $p->{ $StatColumns[$stat] . '_effort_' . $endpoint };
228                next unless Vee::Utils::isnum($value) and $value > 0;
229                my $op = $endpoints{$endpoint}{operator};
230                push @{$criteria{'-and'}}, \ "SUBSTR(effort, $stat + 1, 1) $op $value";
231            }
232        }
233
234        # EVOLUTION STAGE
235        if ($p->{evo_stage}) {
236            my %evo_stages = map { $_ => 1 } Vee::Utils::array($p->{evo_stage});
237
238            if ($evo_stages{base}) {
239                $criteria{'me.evo_parent_id'} = 0;
240            }
241
242            if ($evo_stages{final}) {
243                $joins{descendants} = 1;
244                $criteria{'descendants.id'} = undef;
245            }
246        }
247
248        # SORTING
249        if ($p->{sort} eq 'stat_avg') {
250            $clauses{order_by} = '(' . join(' + ', map { "me.$_" } @StatColumns) . ')';
251        } else {
252            $clauses{order_by} = 'me.' . $p->{sort};
253        }
254        $clauses{order_by} .= $p->{sort_desc} ? ' DESC' : ' ASC';
255
256        $s->{sql} = SQL::Abstract->new->where(\%criteria) if $c->debug;
257        $s->{criteria} = \%criteria;
258
259        my $rs = $c->model('DBIC::Pokemon')->search(
260            \%criteria,
261            { group_by => 'me.id', join => [ keys %joins ], %clauses }
262        );
263
264        my @results;
265        # presence of a HAVING clause will break COUNT(*) so we have to do this nonsense instead
266        if ($clauses{having}) {
267            @results = $rs->all;
268            $s->{total} = scalar @results;
269            @results = @results[$skip .. $#results];
270            @results = @results[0 .. $PAGESIZE - 1] if @results > $PAGESIZE;
271        } else {
272            @results = $rs->search(undef, { rows => $PAGESIZE, offset => $skip });
273            $s->{total} = $rs->count;
274        }
275        $s->{results} = \@results;
276    }
277   
278    $s->{page_title } = "Pok&eacute;mon Search";
279    $s->{link_name  } = 'dex/search';
280    $s->{crumbs     } = [
281        '<a href="' . $c->uri('Dex') . '">Pok&eacute;dex</a>',
282        '<a href="' . $c->uri('Dex', 'pokemon_list') . '">Pok&eacute;mon</a>',
283        'Search',
284    ];
285
286    $s->{template} = 'dex/search/pokemon.tt';
287}
288
289=head2 move_search
290
291Search for moves.
292
293=cut
294
295# move search form
296our $move_search_fields = {
297    name        => { type => 'text', size => 15, title => 'Enter a name or part of a name' },
298    class       => { type => 'select', options => [qw/any physical special/], default => 'any' },
299    generation  => { type => 'checkbox', options => [ map { [ $_ => $Generations[$_]{games} ] } 0 .. $#Generations ] },
300    type        => { type => 'select', options => ['any', @TypeNames], default => 'any' },
301    pokemon     => { type => 'text', count => 6, class => 'js-dexsuggest js-dexsuggest-pokemon', title => 'Enter Pokemon that must be able to learn the move' },
302    move_method => { type => 'checkbox', options => { level => 'Level up', machine => 'TM or HM', egg => 'Egg', tutor => 'Move Tutor' }, title => 'Select ways the Pokemon may learn the move' },
303    move_version=> { type => 'checkbox', options => [ map { [ $_ => $Icons{$_} ] } qw/rb y gs c rusa frlg e dp/ ], title => 'Select ways the Pokemon may learn the move' },
304
305    view        => { type => 'select', options => [[ list => 'boring list' ], [ table => 'detailed table' ], [ contest => 'contest table' ]], title => 'Select how you want the results displayed' },
306    sort        => { type => 'select', options => [qw/name id/], default => 'name', title => 'Select how you want the results ordered' },
307    sort_desc   => { type => 'checkbox', title => 'Check this for descending order' },
308};
309
310for my $col (qw/power accuracy pp effect_chance priority/) {
311    for my $endpoint (keys %endpoints) {
312        $move_search_fields->{ $col . '_' . $endpoint } = { type => 'text', size => 4, maxlength => 3, title => "\u$endpoints{$endpoint}{english} $col" };
313    }
314}
315
316sub move_search : Path('moves/search') : Args(0) {
317    my ($self, $c) = @_;
318    my $s = $c->stash;
319    my $p = $c->req->params;
320    my $skip = $p->{skip} || 0;
321    $s->{before} = {%{ $c->req->params }};
322   
323    my $form = $s->{form} = Vee::Form->new(
324        id => 'move_search',
325        fields => $move_search_fields,
326        params => $c->req->params,
327    );
328   
329    if ($form->submitted) {
330        my %criteria;
331        $criteria{'-and'} = [ { 'me.id' => { '<=', $Generations[-1]{maxmoveid} } } ];
332        my (%joins, %clauses);
333       
334        # BASIC
335        if ($p->{name}) {
336            my $name = $p->{name};
337            if ($name =~ /[*?]/) {
338                $name =~ tr/*?/%_/;
339                $criteria{'me.name'} = { like => $name };
340            } else {
341                $criteria{'me.name'} = { like => "%$name%" };
342            }
343        }
344        if ($p->{class} ne 'any') { $criteria{class} = $p->{class} }
345       
346        # TYPE
347        if ($p->{type} ne 'any') { $criteria{type} = $p->{type} }
348
349
350        # GENERATIONS
351        if (defined $p->{generation}) {
352            my $gen_ref = [];
353            for my $gen (Vee::Utils::array($p->{generation})) {
354                push @$gen_ref, { '-between' => [ ($gen > 0 ? $Generations[$gen - 1]{maxmoveid} : 0) + 1, $Generations[$gen]{maxmoveid} ] };
355            }
356            push @{$criteria{'-and'}}, { 'me.id' => $gen_ref };
357        }
358
359        # POKEMON
360        if (ref $p->{pokemon} eq 'ARRAY') {
361            my @pokemonids;
362            for my $pokemon (@{ $p->{pokemon} }) {
363                # XXX: show some message if a pokemon is invalid?
364                my $pokemonid = get_pokemon($pokemon);
365                push @pokemonids, $pokemonid if defined $pokemonid;
366            }
367            if (@pokemonids) {
368                $joins{pokemon_moves} = 1;
369                $criteria{'pokemon_moves.pokemon_id'} = \@pokemonids;
370                $clauses{having}{'COUNT(DISTINCT pokemon_moves.pokemon_id, pokemon_moves.move_id)'} = scalar @pokemonids;
371
372                if ($p->{move_method}) {
373                    $criteria{'pokemon_moves.method'} = $p->{move_method};
374                }
375                if ($p->{move_version}) {
376                    push @{$criteria{'-and'}}, { '-or', [ map { \ "FIND_IN_SET('$_', pokemon_moves.versions)" } Vee::Utils::array($p->{move_version}) ] };
377                }
378            }
379        }
380
381        # NUMBERS
382        for my $numbar (qw/power accuracy pp effect_chance priority/) {
383            for my $endpoint (keys %endpoints) {
384                my $value = $p->{$numbar . '_' . $endpoint};
385                next unless Vee::Utils::isnum($value) and ($numbar eq 'priority' or $value > 0);
386
387                my $column = $numbar;
388                if ($numbar eq 'priority') {
389                    $joins{effect} = 1;
390                    $column = 'effect.' . $numbar;
391                }
392                $criteria{$column}{ $endpoints{$endpoint}{operator} } = $value;
393            }
394        }
395
396        # SORTING
397        $clauses{order_by} = 'me.' . $p->{sort} . ' ';
398        $clauses{order_by} .= $criteria{sort_desc} ? 'DESC' : 'ASC';
399
400        if ($p->{view} eq 'contest') {
401            push @{ $clauses{prefetch} }, 'contest';
402        } else {
403            $clauses{columns} = ['me.id'];
404        }
405
406        $s->{sql} = SQL::Abstract->new->where(\%criteria) if $c->debug;
407        $s->{criteria} = \%criteria;
408
409        my $rs = $c->model('DBIC::Moves')->search(
410            \%criteria,
411            { order_by => 'me.name ASC', group_by => 'me.id', join => [ keys %joins ], %clauses }
412        );
413
414        my @results;
415        # presence of a HAVING clause will break COUNT(*) so we have to do this nonsense instead
416        if ($clauses{having}) {
417            @results = $rs->all;
418            $s->{total} = scalar @results;
419            @results = @results[$skip .. $#results];
420            @results = @results[0 .. 49] if @results > 50;
421        } else {
422            @results = $rs->search(undef, { rows => 50, offset => $skip });
423            $s->{total} = $rs->count;
424        }
425        $s->{results} = \@results;
426    }
427   
428    $s->{page_title } = "Move Search";
429    $s->{link_name  } = 'dex/movesearch';
430    $s->{crumbs     } = [
431        '<a href="' . $c->uri('Dex') . '">Pok&eacute;dex</a>',
432        '<a href="' . $c->uri('Dex', 'move_list') . '">Moves</a>',
433        'Search',
434    ];
435
436    $s->{template} = 'dex/search/moves.tt';
437}
438
439=head2 parse_height($height, $polarity)
440
441Figure out what a height is supposed to be and convert it to decimeters.
442
443C<$polarity> is either -1 (if this is a lower bound) or 1 (if this is an upper
444bound), and is used to add an error margin to Imperial heights to compensate for
445the database's not storing exact numbers of inches.
446
447=cut
448
449sub parse_height {
450    my ($input, $polarity) = @_;
451    $input =~ s/^\s+|\s+$//g;
452    return if not $input;
453
454    my $number = qr/ (?: \d* \.)? \d+ /x;
455    if ($input =~ /^ ($number) \s* m $/x) {
456        return $1 * 10;
457    } elsif ($input =~ /^ ($number) \s* dm $/x) {
458        return $1;
459    } elsif ($input =~ /^ ($number) \s* cm $/x) {
460        return $1 / 10;
461    } elsif ($input =~ /^ (?: ($number) \s* (?:ft|'))? \s* (?: ($number) \s* (?:in|")?)? $/x) {
462        # NOTE: with the above regex, just a number will give inches
463        return +(($1 || 0) * 12 + ($2 || 0) + $polarity * 0.5) * 0.254;
464    } else {
465        # no idea, sorry
466        return;
467    }
468}
469
470=head2 parse_weight($height, $polarity)
471
472Figure out what a weight is supposed to be and convert it to kilograms.
473
474C<$polarity> is either -1 (if this is a lower bound) or 1 (if this is an upper
475bound), and is used to add an error margin to Imperial weights to compensate for
476the database's not storing exact numbers of ounces.
477
478=cut
479
480sub parse_weight {
481    my ($input, $polarity) = @_;
482    $input =~ s/^\s+|\s+$//g;
483    return if not $input;
484
485    my $number = qr/ (?: \d* \.)? \d+ /x;
486    if ($input =~ /^ ($number) \s* kg $/x) {
487        return $1;
488    } elsif ($input =~ /^ ($number) \s* gm? $/x) {
489        return $1 / 1000;
490    } elsif ($input =~ /^ (?: ($number) \s* (?:lbs?)?)? \s* (?: ($number) \s* oz)? $/x) {
491        # NOTE: with the above regex, just a number will give pounds
492        return +(($1 || 0) * 16 + ($2 || 0) + $polarity * 0.5) * 0.02835;
493    } else {
494        # no idea, sorry
495        return;
496    }
497}
498
499=head1 AUTHOR
500
501Maintainer: Alex "Eevee" Munroe (C<veekun@veekun.com>)
502
503See the included F<AUTHORS> file for a full list of contributers.
504
505=head1 LICENSE
506
507See the included F<LICENSE> file.
508
509=cut
510
5111;
Note: See TracBrowser for help on using the browser.