File Coverage

blib/lib/Game/HexDescribe/Utils.pm
Criterion Covered Total %
statement 328 668 49.1
branch 104 326 31.9
condition 54 161 33.5
subroutine 32 47 68.0
pod 37 38 97.3
total 555 1240 44.7


line stmt bran cond sub pod time code
1             #!/usr/bin/env perl
2             # Copyright (C) 2018–2022 Alex Schroeder
3             #
4             # This program is free software: you can redistribute it and/or modify it under
5             # the terms of the GNU Affero General Public License as published by the Free
6             # Software Foundation, either version 3 of the License, or (at your option) any
7             # later version.
8             #
9             # This program is distributed in the hope that it will be useful, but WITHOUT
10             # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11             # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
12             # details.
13             #
14             # You should have received a copy of the GNU Affero General Public License along
15             # with this program. If not, see .
16              
17             =encoding utf8
18              
19             =head1 NAME
20              
21             Game::HexDescribe::Utils - utilities to use the Hex Describe data
22              
23             =head1 DESCRIPTION
24              
25             L is a web application which uses recursive random tables to
26             create the description of a map. This package contains the functions used to
27             access the information outside the web application framework.
28              
29             =cut
30              
31             package Game::HexDescribe::Utils;
32             require Exporter;
33             our @ISA = qw(Exporter);
34             our @EXPORT_OK = qw(init markdown describe_text list_tables parse_table load_table
35             describe_map parse_map load_map);
36 6     6   3378 use Text::Autoformat;
  6         117798  
  6         559  
37 6     6   2453 use Game::HexDescribe::Log;
  6         15  
  6         198  
38 6     6   42 use Modern::Perl;
  6         10  
  6         74  
39 6     6   1766 use Mojo::URL;
  6         10  
  6         55  
40 6     6   160 use Mojo::File;
  6         15  
  6         265  
41 6     6   50 use List::Util qw(shuffle sum);
  6         9  
  6         424  
42 6     6   2308 use Array::Utils qw(intersect);
  6         1913  
  6         409  
43 6     6   44 use Encode qw(decode_utf8);
  6         10  
  6         277  
44 6     6   92 use utf8;
  6         13  
  6         41  
45              
46             my $log = Game::HexDescribe::Log->get;
47              
48             our $face_generator_url;
49             our $text_mapper_url;
50              
51             =item list_tables($dir)
52              
53             This function returns the table names in $dir. These are based on the following
54             filename convention: "$dir/hex-describe-$name-table.txt".
55              
56             =cut
57              
58             sub list_tables {
59 0     0 1 0 my $dir = shift;
60 0         0 $log->debug("Looking for maps in the contrib directory: $dir");
61 0         0 my @names = map { $_->basename('.txt') } Mojo::File->new($dir)->list->each;
  0         0  
62 0 0       0 return grep { $_ } map { $1 if /^hex-describe-(.*)-table$/ } @names;
  0         0  
  0         0  
63             }
64              
65             =item load_table($name, $dir)
66              
67             This function returns the unparsed table from the filename
68             "$dir/hex-describe-$name-table.txt".
69              
70             =cut
71              
72             sub load_table {
73 7     7 1 63 my ($name, $dir) = @_;
74 7         56 $log->debug("Looking for table '$name' in the contrib directory '$dir'");
75 7         110 my $file = Mojo::File->new("$dir/hex-describe-$name-table.txt");
76 7 50       99 return decode_utf8($file->slurp) if -e $file;
77 0         0 return '';
78             }
79              
80             =item load_map($name, $dir)
81              
82             This function returns the unparsed map from the filename
83             "$dir/hex-describe-$name-map.txt".
84              
85             =cut
86              
87             sub load_map {
88 1     1 1 227 my ($name, $dir) = @_;
89 1         7 $log->debug("Looking for map in the contrib directory: $dir");
90 1         15 my $file = Mojo::File->new("$dir/hex-describe-$name-map.txt");
91 1 50       13 return decode_utf8($file->slurp) if -e $file;
92             }
93              
94             =item parse_table
95              
96             This parses the random tables. This is also where *bold* gets translated to
97             HTML. We also do some very basic checking of references. If we refer to another
98             table in square brackets we check whether we've seen such a table.
99              
100             Table data is a reference to a hash of hashes. The key to the first hash is the
101             name of the table; the key to the second hash is "total" for the number of
102             options and "lines" for a reference to a list of hashes with two keys, "count"
103             (the weight of this lines) and "text" (the text of this line).
104              
105             A table like the following:
106              
107             ;tab
108             1,a
109             2,b
110              
111             Would be:
112              
113             $table_data->{tab}->{total} == 3
114             $table_data->{tab}->{lines}->[0]->{count} == 1
115             $table_data->{tab}->{lines}->[0]->{text} eq "a"
116             $table_data->{tab}->{lines}->[1]->{count} == 2
117             $table_data->{tab}->{lines}->[1]->{text} eq "b"
118              
119             =cut
120              
121             my $dice_re = qr/^(save )?(?:(\d+)d(\d+)(?:x(\d+))?(?:([+-]\d+))?(?:>=(-?\d+))?(?:<=(-?\d+))?|(\d+))(?: as (.+))?$/;
122             my $math_re = qr/^(save )?([-+*\/%<>=()0-9]+)(?: as (.+))?$/;
123              
124             sub parse_table {
125 7     7 1 28065 my $text = shift;
126 7         1645 $log->debug("parse_table: parsing " . length($text) . " characters");
127 7         98 my $data = {};
128 7         18 my $words = "[^\[\]\n]*";
129 7         90 my (%aliases, $key, $c, $t);
130 7         243486 for my $line (split(/\r?\n/, $text)) {
131 285047 100 100     1349342 if ($line =~ /^;([^#\r\n]+)/) {
    100 66        
    50          
132 14553         24022 $key = $1;
133 14553 50       31168 $log->warn("parse_table: reset '$key'") if exists $data->{$key};
134 14553         45669 $data->{$key} = {}; # reset, don't merge
135             } elsif ($key and ($c, $t) = $line =~ /^(\d+),(.*)/) {
136 253316         385346 $t =~ s/\*\*(.*?)\*\*/$1<\/strong>/g;
137 253316         351869 $t =~ s/\*(.*?)\*/$1<\/em>/g;
138 253316         500584 my %h = (text => $t);
139 253316 100       423504 if ($c == 0) {
140 42         136 $h{unique} = 1;
141 42         74 $c = 1;
142             }
143 253316         405418 $h{count} = $c;
144 253316         363279 $data->{$key}->{total} += $c;
145 253316         290813 push(@{$data->{$key}->{lines}}, \%h);
  253316         476512  
146             # [foo as bar]
147 253316         839518 for my $alias ($h{text} =~ /\[$words as ($words)\]/g) {
148 0         0 $aliases{$alias} = 1;
149             }
150             # [foo [baz] quux as bar] (one level of indirection allowed
151 253316         784610 for my $alias ($h{text} =~ /\[$words\[$words\]$words as ($words)\]/g) {
152 0         0 $aliases{$alias} = 1;
153             }
154             } elsif ($line ne '' and $line !~ /^\s*#/) {
155 0         0 $log->warn("unknown line type: '$line'");
156             }
157             }
158             # check tables
159 7         31425 for my $table (keys %$data) {
160 14553         17395 for my $line (@{$data->{$table}->{lines}}) {
  14553         41924  
161 253316         643465 for my $subtable ($line->{text} =~ /\[($words)\]/g) {
162 0 0       0 next if index($subtable, '|') != -1;
163 0 0       0 next if $subtable =~ /$dice_re/;
164 0 0       0 next if $subtable =~ /$math_re/;
165 0 0       0 next if $subtable =~ /^redirect https?:/;
166 0 0 0     0 next if $subtable =~ /^names for (.*)/ and $data->{"name for $1"};
167 0 0 0     0 next if $subtable =~ /^(?:capitalize|titlecase|highlightcase|normalize-elvish) (.*)/ and $data->{$1};
168 0 0       0 next if $subtable =~ /^adjacent hex$/; # experimental
169 0 0 0     0 next if $subtable =~ /^same (.*)/ and ($data->{$1} or $aliases{$1} or $1 eq 'adjacent hex');
      0        
170 0 0 0     0 next if $subtable =~ /^(?:here|nearby|closest|other|append|later|with|and|save|store) (.+?)( as (.+))?$/ and $data->{$1};
171 0 0       0 $subtable = $1 if $subtable =~ /^(.+) as (.+)/;
172             $log->error("Error in table $table: subtable $subtable is missing")
173 0 0       0 unless $data->{$subtable};
174             }
175             }
176             }
177 7         2937 return $data;
178             }
179              
180             =item init
181              
182             When starting a description, we need to initialize our data. There are two
183             global data structures beyond the map.
184              
185             B<$extra> is a reference to a hash of lists of hashes used to keep common data
186             per line. In this context, lines are linear structures like rivers or trails on
187             the map. The first hash uses the hex coordinates as a key. This gets you the
188             list of hashes, one per line going through this hex. Each of these hashes uses
189             the key "type" to indicate the type of line, "line" for the raw data (for
190             debugging), and later "name" will be used to name these lines.
191              
192             $extra->{"0101"}->[0]->{"type"} eq "river"
193              
194             B<%names> is just a hash of names. It is used for all sorts of things. When
195             using the reference C, then "name for a bugbear band1"
196             will be a key in this hash. When using the reference C,
197             then "name for forest foo: 0101" and will be set for every hex sharing that
198             name.
199              
200             $names{"name for a bugbear band1"} eq "Long Fangs"
201             $names{"name for forest foo: 0101"} eq "Dark Wood"
202              
203             Note that for C, C is called for every paragraph.
204              
205             B<%locals> is a hash of all the "normal" table lookups encountered so far. It is
206             reset for every paragraph. To refer to a previous result, start a reference
207             with the word "same". This doesn't work for references to adjacent hexes, dice
208             rolls, or names. Here's an example:
209              
210             ;village boss
211             1,[man] is the village boss. They call him Big [same man].
212             1,[woman] is the village boss. They call her Big [same woman].
213              
214             Thus:
215              
216             $locals{man} eq "Alex"
217              
218             B<%globals> is a hash of hashes of all the table lookups beginning with the word
219             "here" per hex. In a second phase, all the references starting with the word
220             "nearby" will be resolved using these. Here's an example:
221              
222             ;ingredient
223             1,fey moss
224             1,blue worms
225             ;forest
226             3,There is nothing here but trees.
227             1,You find [here ingredient].
228             ;village
229             1,The alchemist needs [nearby ingredient].
230              
231             Some of the forest hexes will have one of the two possible ingredients and the
232             village alchemist will want one of the nearby ingredients. Currently, there is a
233             limitation in place: we can only resolve the references starting with the word
234             "nearby" when everything else is done. This means that at that point, references
235             starting with the word "same" will no longer work since C<%locals> will no
236             longer be set.
237              
238             Thus:
239              
240             $globals->{ingredient}->{"0101"} eq "fey moss"
241              
242             =cut
243              
244             my $extra;
245             my %names;
246             my %locals;
247             my $globals;
248              
249             sub init {
250 6     6 1 16 %names = ();
251 6         15 %locals = ();
252 6         13 $globals = undef;
253 6         13 $extra = undef;
254             }
255              
256             =item parse_map_data
257              
258             This does basic parsing of hexes on the map as produced by Text Mapper, for
259             example:
260              
261             0101 dark-green trees village
262              
263             =cut
264              
265             sub parse_map_data {
266 1     1 1 2 my $map = shift;
267 1         1 my $map_data;
268 1 50 33     18 if ($map and $map->isa('Mojo::Upload')) {
269 0         0 $map = $map->slurp();
270             };
271 1         11 for my $hex (split(/\r?\n/, $map)) {
272 1 50       11 if (my ($x, $y) = $hex =~ /^(\d\d)(\d\d)\s*empty$/cg) {
    50          
273             # skip
274             } elsif (($x, $y) = $hex =~ /^(\d\d)(\d\d)\s+/cg) {
275 1         4 my @types = ("system"); # Traveller
276 1   66     10 while($hex =~ /\G([a-z]="[^"]+")\s*/cg or $hex =~ /(\S+)/cg) {
277 1         7 push(@types, $1);
278             }
279 1         8 $map_data->{"$x$y"} = \@types;
280             }
281             }
282 1         2 return $map_data;
283             }
284              
285             =item parse_map_lines
286              
287             This does basic parsing of linear structures on the map as produced by Text
288             Mapper, for example:
289              
290             0302-0101 trail
291              
292             We use C to find all the missing points on the line.
293              
294             =cut
295              
296             my $line_re = qr/^(\d\d\d\d(?:-\d\d\d\d)+)\s+(\S+)/m;
297              
298             sub parse_map_lines {
299 1     1 1 3 my $map = shift;
300 1         2 my @lines;
301 1         13 while ($map =~ /$line_re/g) {
302 0         0 my ($line, $type) = ($1, $2);
303 0         0 my @points = compute_missing_points(split(/-/, $line));
304 0         0 push(@lines, [$type, @points]);
305             }
306 1         3 return \@lines;
307             }
308              
309             =item process_map_merge_lines
310              
311             As we process lines, we want to do two things: if a hex is part of a linear
312             structure, we want to add the B to the terrain features. Thus, given the
313             following hex and river, we want to add "river" to the terrain features of 0101:
314              
315             0801-0802-0703-0602-0503-0402-0302-0201-0101-0100 river
316              
317             The (virtual) result:
318              
319             0101 dark-green trees village river
320              
321             Furthermore, given another river like the following, we want to merge these
322             where they meet (at 0302):
323              
324             0701-0601-0501-0401-0302-0201-0101-0100 river
325              
326             Again, the (virtual) result:
327              
328             0302 dark-green trees town river river-merge
329              
330             If you look at the default map, here are some interesting situations:
331              
332             A river starts at 0906 but it immediately merges with the river starting at 1005
333             thus it should be dropped entirely.
334              
335             A trail starts at 0206 and passes through 0305 on the way to 0404 but it
336             shouldn't end at 0305 just because there's also a trail starting at 0305 going
337             north to 0302.
338              
339             =cut
340              
341             sub process_map_merge_lines {
342 1     1 1 2 my $map_data = shift;
343 1         2 my $lines = shift;
344 1         2 for my $line (@$lines) {
345 0         0 my $type = $line->[0];
346 0         0 my %data = (type => $type, line => $line);
347             # $log->debug("New $type...");
348 0         0 my $start = 1;
349             COORD:
350 0         0 for my $i (1 .. $#$line) {
351 0         0 my $coord = $line->[$i];
352             # don't add data for hexes outside the map
353 0 0       0 last unless $map_data->{$coord};
354             # don't start a line going in the same direction as an existing line in
355             # the same hex (e.g. 0906) but also don't stop a line if it runs into a
356             # merge and continues (e.g. 0305)
357 0         0 my $same_dir = 0;
358 0         0 for my $line2 (grep { $_->{type} eq $type } @{$extra->{$coord}}) {
  0         0  
  0         0  
359 0 0       0 if (same_direction($coord, $line, $line2->{line})) {
360             # $log->debug("... at $coord, @$line and @{$line2->{line}} go in the same direction");
361 0         0 $same_dir = 1;
362 0         0 last;
363             }
364             }
365 0 0 0     0 if ($start and $same_dir) {
366             # $log->debug("... skipping");
367 0         0 last COORD;
368             }
369             # add type to the hex description, add "$type-merge" when
370             # running into an existing one
371 0         0 my $merged;
372 0 0       0 if (not grep { $_ eq $type } @{$map_data->{$coord}}) {
  0 0       0  
  0         0  
373             # $log->debug("...$type leading into $coord");
374 0         0 push(@{$map_data->{$coord}}, $type);
  0         0  
375 0         0 } elsif (not grep { $_ eq "$type-merge" } @{$map_data->{$coord}}) {
  0         0  
376 0         0 $merged = $same_dir; # skip the rest of the line, if same dir
377             # $log->debug("...noted merge into existing $type at $coord");
378 0         0 push(@{$map_data->{$coord}}, "$type-merge");
  0         0  
379             } else {
380 0         0 $merged = $same_dir; # skip the rest of the line, if same dir
381             # $log->debug("...leads into existing $type merge at $coord");
382             }
383 0         0 $start = 0;
384             # all hexes along a line share this hash
385 0         0 push(@{$extra->{$coord}}, \%data);
  0         0  
386             # if a river merges into another, don't add any hexes downriver
387 0 0       0 last if $merged;
388             }
389             }
390             }
391              
392             =item process_map_start_lines
393              
394             As we process lines, we also want to note the start of lines: sources of rivers,
395             the beginning of trails. Thus, given the following hex and river, we want to add
396             "river-start" to the terrain features of 0801:
397              
398             0801-0802-0703-0602-0503-0402-0302-0201-0101-0100 river
399              
400             Adds a river to the hex:
401              
402             0801 light-grey mountain river river-start
403              
404             But note that we don't want to do this where linear structures have merged. If a
405             trail ends at a town and merges with other trails there, it doesn't "start"
406             there. It can only be said to start somewhere if no other linear structure
407             starts there.
408              
409             In case we're not talking about trails and rivers but things like routes from A
410             to B, it might be important to note the fact. Therefore, both ends of the line
411             get a "river-end" (if a river).
412              
413             =cut
414              
415             sub process_map_start_lines {
416 1     1 1 15 my $map_data = shift;
417 1         2 my $lines = shift;
418             # add "$type-start" to the first and last hex of a line, unless it is a merge
419 1         2 for my $line (@$lines) {
420 0         0 my $type = $line->[0];
421 0         0 for my $coord ($line->[1], $line->[$#$line]) {
422             # ends get marked either way
423 0 0       0 push(@{$map_data->{$coord}}, "$type-end") unless grep { $_ eq "$type-end" } @{$map_data->{$coord}};
  0         0  
  0         0  
  0         0  
424             # skip hexes outside the map
425 0 0       0 last unless $map_data->{$coord};
426             # skip merges
427 0 0       0 last if grep { $_ eq "$type-merge" } @{$map_data->{$coord}};
  0         0  
  0         0  
428             # add start
429 0         0 push(@{$map_data->{$coord}}, "$type-start");
  0         0  
430             }
431             }
432             }
433              
434             =item parse_map
435              
436             This calls all the map parsing and processing functions we just talked about.
437              
438             =cut
439              
440             sub parse_map {
441 1     1 1 2 my $map = shift;
442 1         3 my $map_data = parse_map_data($map);
443 1         4 my $lines = parse_map_lines($map);
444             # longest rivers first
445 1         3 @$lines = sort { @$b <=> @$a } @$lines;
  0         0  
446             # for my $line (@$lines) {
447             # $log->debug("@$line");
448             # }
449 1         3 process_map_merge_lines($map_data, $lines);
450 1         3 process_map_start_lines($map_data, $lines);
451             # for my $coord (sort keys %$map_data) {
452             # $log->debug(join(" ", $coord, @{$map_data->{$coord}}));
453             # }
454 1         3 return $map_data;
455             }
456              
457             =item pick_description
458              
459             Pick a description from a given table. In the example above, pick a random
460             number between 1 and 3 and then go through the list, addin up counts until you
461             hit that number.
462              
463             If the result picked is unique, remove it from the list. That is, set it's count
464             to 0 such that it won't ever get picked again.
465              
466             =cut
467              
468             sub pick_description {
469 395     395 1 477 my $h = shift;
470 395         580 my $total = $h->{total};
471 395         483 my $lines = $h->{lines};
472 395         648 my $roll = int(rand($total)) + 1;
473 395         465 my $i = 0;
474 395         579 for my $line (@$lines) {
475 17407         18729 $i += $line->{count};
476 17407 100       21840 if ($i >= $roll) {
477 395 50       638 if ($line->{unique}) {
478 0         0 $h->{total} -= $line->{count};
479 0         0 $line->{count} = 0;
480             }
481 395         845 return $line->{text};
482             }
483             }
484 0         0 $log->error("picked nothing");
485 0         0 return '';
486             }
487              
488             =item resolve_redirect
489              
490             This handles the special redirect syntax: request an URL and if the response
491             code is a 301 or 302, take the location header in the response and return it.
492              
493             If the environment variable C is set, these do not get
494             resolved and the empty string is returned.
495              
496             =cut
497              
498             sub resolve_redirect {
499             # If you install this tool on a server using HTTPS, then some browsers will
500             # make sure that including resources from other servers will not work.
501 10     10 1 31 my $url = shift;
502 10         18 my $redirects = shift;
503 10 50 33     77 return '' unless $redirects and not $ENV{HEX_DESCRIBE_OFFLINE};
504             # Special case because table writers probably used the default face generator URL
505 0 0       0 $url =~ s!^https://campaignwiki\.org/face!$face_generator_url! if $face_generator_url;
506 0 0       0 $url =~ s!^https://campaignwiki\.org/text-mapper!$text_mapper_url! if $text_mapper_url;
507 0         0 my $ua = Mojo::UserAgent->new;
508 0         0 my $res = eval { $ua->get($url)->result };
  0         0  
509 0 0 0     0 if (not $res) {
    0          
510 0         0 my $warning = $@;
511 0         0 chomp($warning);
512 0         0 $log->warn("connecting to $url: $warning");
513 0         0 return "";
514             } elsif ($res->code == 301 or $res->code == 302) {
515 0         0 return Mojo::URL->new($res->headers->location)
516             ->base(Mojo::URL->new($url))
517             ->to_abs;
518             }
519 0         0 $log->info("resolving redirect for $url did not result in a redirection");
520 0         0 return $url;
521             }
522              
523             =item pick
524              
525             This function picks the appropriate table given a particular word (usually a map
526             feature such as "forest" or "river").
527              
528             This is where I is implemented. Let's start with this hex:
529              
530             0101 dark-green trees village river trail
531              
532             Remember that parsing the map added more terrain than was noted on the map
533             itself. Our function will get called for each of these words, Let's assume it
534             will get called for "dark-green". Before checking whether a table called
535             "dark-green" exists, we want to check whether any of the other words provide
536             enough context to pick a more specific table. Thus, we will check "trees
537             dark-green", "village dark-green", "river dark-green" and "trail dark-green"
538             before checking for "dark-green".
539              
540             If such a table exists in C<$table_data>, we call C to pick a
541             text from the table and then we go through the text and call C to
542             resolve any table references in square brackets.
543              
544             Remember that rules for the remaining words are still being called. Thus, if you
545             write a table for "trees dark-green" (which is going to be picked in preference
546             to "dark-green"), then there should be no table for "trees" because that's the
547             next word that's going to be processed!
548              
549             =cut
550              
551             sub pick {
552 396     396 1 443 my $map_data = shift;
553 396         450 my $table_data = shift;
554 396         455 my $level = shift;
555 396         460 my $coordinates = shift;
556 396         462 my $words = shift;
557 396         543 my $word = shift;
558 396         433 my $redirects = shift;
559 396         454 my $text;
560             # Make sure we're testing all the context combinations first. Thus, if $words
561             # is [ "mountains" white" "chaos"] and $word is "mountains", we want to test
562             # "white mountains", "cold mountains" and "mountains", in this order.
563 396         546 for my $context (grep( { $_ ne $word } @$words), $word) {
  398         746  
564 414 100       640 my $key = ($context eq $word ? $word : "$context $word");
565             # $log->debug("$coordinates: looking for a $key table") if $coordinates eq "0109";
566 414 100       1970 if ($table_data->{$key}) {
567 395         662 $text = pick_description($table_data->{$key});
568             # $log->debug("$coordinates → $key → $text");
569 395         560 my $seed = int(rand(~0)); # maxint
570 395         635 $text =~ s/\[\[redirect (https:.*?)\]\]/my $url = $1; $url =~ s!\$seed!$seed!; resolve_redirect($url, $redirects)/ge;
  10         20  
  10         43  
  10         21  
571             # this makes sure we recursively resolve all references, in order, because
572             # we keep rescanning from the beginning
573 395         496 my $last = $text;
574 395         1026 while ($text =~ s/\[([^][]*)\]/describe($map_data,$table_data,$level+1,$coordinates,[$1], $redirects)/e) {
  550         2951  
575 550 50       1109 if ($last eq $text) {
576 0         0 $log->error("Infinite loop: $text");
577 0         0 last;
578             }
579 550         1764 $last = $text;
580             };
581 395         576 last;
582             }
583             }
584             # $log->debug("$word → $text ") if $text;
585 396         761 return $text;
586             }
587              
588             =item describe
589              
590             This is where all the references get resolved. We handle references to dice
591             rolls, the normal recursive table lookup, and all the special rules for names
592             that get saved once they have been determined both globally or per terrain
593             features. Please refer to the tutorial on the help page for the various
594             features.
595              
596             =cut
597              
598             sub describe {
599 569     569 1 735 my $map_data = shift;
600 569         637 my $table_data = shift;
601 569         708 my $level = shift;
602 569         727 my $coordinates = shift;
603 569         677 my $words = shift;
604 569         620 my $redirects = shift;
605 569 50       940 $log->error("Recursion level $level exceeds 20 in $coordinates (@$words)!") if $level > 20;
606 569 50       907 return '' if $level > 20;
607 569 100       924 if ($level == 1) {
608 3         22 %locals = (hex => $coordinates); # reset once per paragraph
609 3         6 for my $word (@$words) {
610 4 50 33     31 if ($word =~ /^([a-z]+)="(.*)"/ or
611             $word =~ /(.*)-(\d+)$/) {
612             # assigments in the form uwp=“777777” assign “777777” to “uwp”
613             # words in the form law-5 assign “5” to “law”
614 0         0 $locals{$1} = $2;
615             } else {
616 4         13 $locals{$word} = 1;
617             }
618             }
619             }
620 569         627 my @descriptions;
621 569         788 for my $word (@$words) {
622             # valid dice rolls: 1d6, 1d6+1, 1d6x10, 1d6x10+1
623 570 100 0     8798 if (my ($just_save, $n, $d, $m, $p, $min, $max, $c, $save_as) = $word =~ /$dice_re/) {
    50 0        
    50 0        
    50 0        
    50          
    50          
    100          
    50          
    50          
    50          
    50          
    50          
    100          
    100          
    100          
    100          
    50          
    50          
    100          
    50          
    0          
    0          
624 42         65 my $r = 0;
625 42 50       72 if ($c) {
626 0         0 $r = $c;
627             } else {
628 42         100 for(my $i = 0; $i < $n; $i++) {
629 58         211 $r += int(rand($d)) + 1;
630             }
631 42   100     125 $r *= $m||1;
632 42   100     118 $r += $p||0;
633 42 50 33     102 $r = $min if defined $min and $r < $min;
634 42 50 33     97 $r = $max if defined $max and $r > $max;
635             }
636             # $log->debug("rolling dice: $word = $r");
637 42 100       93 $locals{$save_as} = $r if $save_as;
638 42 50       124 push(@descriptions, $r) unless $just_save;
639             } elsif (my ($save, $expression, $as) = $word =~ /$math_re/) {
640 0         0 my $r = eval($expression);
641 0 0       0 $locals{$as} = $r if $as;
642 0 0       0 push(@descriptions, $r) unless $save;
643             } elsif ($word =~ /^(\S+)\?\|\|(.*)/) {
644             # [a?||b] return b if a is defined, or nothing
645 0 0       0 push(@descriptions, $2) if $locals{$1};
646             } elsif ($word =~ /^!(\S+)\|\|(.*)/) {
647             # [!a||b] return b if a is undefined
648 0 0       0 push(@descriptions, $2) if not $locals{$1};
649             } elsif (index($word, "||") != -1) {
650             # [a||b] returns a if defined, otherwise b
651 0         0 for my $html (split(/\|\|/, $word)) {
652 0         0 my $copy = $html;
653 0         0 $copy =~ s/<.*?>|…//g; # strip tags, e.g. span elements, and ellipsis
654 0 0       0 if ($copy =~ /\S/) {
655 0         0 push(@descriptions, $html);
656 0         0 last;
657             }
658             }
659             } elsif (index($word, "|") != -1) {
660             # [a|b] returns one of a or b
661 0         0 push(@descriptions, one(split(/\|/, $word)));
662             } elsif ($word =~ /^name for an? /) {
663             # for global things like factions, dukes
664 7         11 my $name = $names{$word};
665             # $log->debug("memoized: $word is $name") if $name;
666 7 100       16 return $name if $name;
667 6         15 $name = pick($map_data, $table_data, $level, $coordinates, $words, $word, $redirects);
668 6 50       12 next unless $name;
669 6         16 $names{$word} = $name;
670             # $log->debug("$word is $name");
671 6         11 push(@descriptions, $name);
672             } elsif ($word =~ /^names for (\S+)/) {
673 0         0 my $key = $1; # "river"
674             # $log->debug("Looking at $key for $coordinates...");
675 0 0       0 if (my @lines = grep { $_->{type} eq $key } @{$extra->{$coordinates}}) {
  0         0  
  0         0  
676             # $log->debug("...@lines");
677             # make sure all the lines (rivers, trails) are named
678 0         0 my @names = ();
679 0         0 for my $line (@lines) {
680 0         0 my $name = $line->{name};
681 0 0       0 if (not $name) {
682 0   0     0 $name ||= pick($map_data, $table_data, $level, $coordinates, $words, "name for $key", $redirects);
683 0         0 $line->{name} = $name;
684             }
685 0         0 push(@names, $name);
686             }
687 0         0 my $list;
688 0 0       0 if (@names > 2) {
    0          
689 0         0 $list = join(", ", @names[0 .. $#names-1], "and " . $names[-1]);
690             } elsif (@names == 2) {
691 0         0 $list = join(" and ", @names);
692             } else {
693 0         0 $log->error("$coordinates has merge but just one line (@lines)");
694 0         0 $list = shift(@names);
695             }
696 0 0       0 $log->error("$coordinates uses merging rule without names") unless $list;
697 0 0       0 next unless $list;
698 0         0 push(@descriptions, $list);
699             }
700             } elsif ($word =~ /^name for (\S+)/) {
701 0         0 my $key = $1; # "white" or "river"
702             # $log->debug("Looking at $key for $coordinates...");
703 0 0       0 if (my @lines = grep { $_->{type} eq $key } @{$extra->{$coordinates}}) {
  0         0  
  0         0  
704             # for rivers and the like: "name for river"
705 0         0 for my $line (@lines) {
706             # $log->debug("Looking at $word for $coordinates...");
707 0         0 my $name = $line->{name};
708             # $log->debug("... we already have a name: $name") if $name;
709             # if a type appears twice for a hex, this returns the same name for all of them
710 0 0       0 return $name if $name;
711 0         0 $name = pick($map_data, $table_data, $level, $coordinates, $words, $word, $redirects);
712             # $log->debug("... we picked a new name: $name") if $name;
713 0 0       0 next unless $name;
714 0         0 push(@descriptions, $name);
715 0         0 $line->{name} = $name;
716 0         0 $globals->{$key}->{$_} = $name for @{$line->{line}}[1..$#{$line->{line}}];
  0         0  
  0         0  
717             # name the first one without a name, don't keep adding names
718 0         0 last;
719             }
720             } else {
721             # regular features: "name for white big mountain"
722 0         0 my $name = $names{"$word: $coordinates"}; # "name for white big mountain: 0101"
723             # $log->debug("$word for $coordinates is $name") if $name;
724 0 0       0 return $name if $name;
725 0         0 $name = pick($map_data, $table_data, $level, $coordinates, $words, $word, $redirects);
726             # $log->debug("new $word for $coordinates is $name") if $name;
727 0 0       0 next unless $name;
728 0         0 $names{"$word: $coordinates"} = $name;
729 0         0 push(@descriptions, $name);
730 0 0       0 spread_name($map_data, $coordinates, $word, $key, $name) if %$map_data;
731             }
732             } elsif ($word eq 'adjacent hex') {
733             # experimental
734 0 0       0 my $location = $coordinates eq 'no map' ? 'somewhere' : one(neighbours($map_data, $coordinates));
735 0         0 $locals{$word} = $location;
736 0         0 return $location;
737             } elsif ($word =~ /^(?:nearby|closest|other|later) ./) {
738             # skip on the first pass
739 0         0 return "「$word」";
740             } elsif ($word =~ /^append (.*)/) {
741 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $1, $redirects);
742             # remember it's legitimate to have no result for a table
743 0 0       0 next unless $text;
744 0         0 $locals{$word} = $text;
745 0         0 push(@descriptions, "「append $text」");
746             } elsif ($word =~ /^same (.+)/) {
747 95         150 my $key = $1;
748 95 50 33     373 return $locals{$key}->[0] if exists $locals{$key} and ref($locals{$key}) eq 'ARRAY';
749 95 50       400 return $locals{$key} if exists $locals{$key};
750 0 0 0     0 return $globals->{$key}->{global} if $globals->{$key} and $globals->{$key}->{global};
751 0         0 $log->warn("[same $key] is undefined for $coordinates");
752 0         0 push(@descriptions, "…");
753             } elsif ($word =~ /^(?:(here|global) )?with (.+?)(?: as (.+))?$/) {
754 2         7 my ($where, $key, $alias) = ($1, $2, $3);
755 2         5 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
756 2 50       9 next unless $text;
757 2         7 $locals{$key} = [$text]; # start a new list
758 2 50       5 $locals{$alias} = $text if $alias;
759 2 50 33     6 $globals->{$key}->{$coordinates} = $text if $where and $where eq 'here';
760 2 0 33     6 $globals->{$alias}->{$coordinates} = $text if $where and $where eq 'here' and $alias;
      33        
761 2 50 33     5 $globals->{$key}->{global} = $text if $where and $where eq 'global';
762 2 0 33     9 $globals->{$alias}->{global} = $text if $where and $where eq 'global' and $alias;
      33        
763 2         5 push(@descriptions, $text);
764             } elsif ($word =~ /^(?:(here|global) )?and (.+?)(?: as (.+))?$/) {
765 2         5 my ($where, $key, $alias) = ($1, $2, $3);
766 2         3 my $found = 0;
767             # limited attempts to find a unique entry for an existing list (instead of
768             # modifying the data structures)
769 2         6 for (1 .. 20) {
770 5         11 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
771 5 50       13 $log->warn("[and $key] is used before [with $key] is done in $coordinates") if ref $locals{$key} ne 'ARRAY';
772 5 50       10 $locals{$key} = [$text] if ref $locals{$key} ne 'ARRAY';
773 5 100 66     10 next if not $text or grep { $text eq $_ } @{$locals{$key}};
  5         15  
  5         8  
774 2         3 push(@{$locals{$key}}, $text);
  2         5  
775 2         4 push(@descriptions, $text);
776 2 50       6 $locals{$alias} = $text if $alias;
777 2 50 33     9 $globals->{$key}->{$coordinates} = $text if $where and $where eq 'here';
778 2 0 33     7 $globals->{$alias}->{$coordinates} = $text if $where and $where eq 'here' and $alias;
      33        
779 2 50 33     4 $globals->{$key}->{global} = $text if $where and $where eq 'global';
780 2 0 33     7 $globals->{$alias}->{global} = $text if $where and $where eq 'global' and $alias;
      33        
781 2         2 $found = 1;
782 2         3 last;
783             }
784 2 50       6 if (not $found) {
785 0         0 $log->warn("[and $key] not unique in $coordinates");
786 0         0 push(@descriptions, "…");
787             }
788             } elsif ($word =~ /^capitalize (.+)/) {
789 1         3 my $key = $1;
790 1         3 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
791 1 50       4 next unless $text;
792 1         3 $locals{$key} = $text;
793 1         4 push(@descriptions, ucfirst $text);
794             } elsif ($word =~ /^titlecase (.+)/) {
795 0         0 my $key = $1;
796 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
797 0 0       0 next unless $text;
798 0         0 $locals{$key} = $text;
799 0         0 push(@descriptions, autoformat($text, { case => 'titlecase' }));
800             } elsif ($word =~ /^highlightcase (.+)/) {
801 0         0 my $key = $1;
802 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
803 0 0       0 next unless $text;
804 0         0 $locals{$key} = $text;
805 0         0 push(@descriptions, autoformat($text, { case => 'highlight' }));
806             } elsif ($word =~ /^normalize-elvish (.+)/) {
807 6         12 my $key = $1;
808 6         15 my $text = normalize_elvish($key);
809 6 50       13 next unless $text;
810 6         21 $locals{$key} = $text;
811 6         10 push(@descriptions, $text);
812             } elsif ($word =~ /^(?:(here|global) )?(?:(save|store|quote) )?(.+?)(?: as (.+))?$/) {
813 415         1041 my ($where, $action, $key, $alias) = ($1, $2, $3, $4);
814 415         519 my $text;
815 415 100 100     870 if (not $action or $action eq "save") {
816             # no action and save are with lookup
817 382         675 $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
818             } else {
819             # quote and store are without lookup
820 33         44 $text = $key;
821             }
822 415 100       766 next unless $text;
823 336         668 $locals{$key} = $text;
824 336 100       556 $locals{$alias} = $text if $alias;
825 336 100 100     549 $globals->{$key}->{$coordinates} = $text if $where and $where eq 'here';
826 336 100 100     536 $globals->{$alias}->{$coordinates} = $text if $where and $where eq 'here' and $alias;
      66        
827 336 100 100     574 $globals->{$key}->{global} = $text if $where and $where eq 'global';
828 336 100 100     548 $globals->{$alias}->{global} = $text if $where and $where eq 'global' and $alias;
      66        
829 336 100 66     879 push(@descriptions, $text) if not $action or $action eq "quote";
830             } elsif ($level > 1 and not exists $table_data->{$word} and not $locals{$word}) {
831             # on level one, many terrain types do not exist (e.g. river-start)
832 0         0 $log->error("unknown table for $coordinates/$level: $word");
833             } elsif ($level > 1 and not $table_data->{$word} and not $locals{$word}) {
834             # on level one, many terrain types do not exist (e.g. river-start)
835 0         0 $log->error("empty table for $coordinates/$level: $word");
836             } else {
837 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $word, $redirects);
838             # remember it's legitimate to have no result for a table, and remember we
839             # cannot use a local with the same name that's defined because sometimes
840             # locals are simply defined as "1" since they start out as "words" and I
841             # don't want to make "1" a special case to ignore, here
842 0 0       0 next unless defined $text;
843 0         0 $locals{$word} = $text;
844 0         0 push(@descriptions, $text);
845             }
846             }
847 473         2187 return join(' ', @descriptions);
848             }
849              
850             =item describe_text
851              
852             This function does what C does, but for simple text without hex
853             coordinates.
854              
855             =cut
856              
857             sub describe_text {
858 5     5 1 18 my $input = shift;
859 5         12 my $table_data = shift;
860 5         10 my $redirects = shift;
861 5         11 my @descriptions;
862 5         155 init();
863 5         61 for my $text (split(/\r?\n/, $input)) {
864             # recusion level 2 makes sure we don't reset %locals
865 16         84 $text =~ s/\[(.*?)\]/describe({},$table_data,2,"no map",[$1],$redirects)/ge;
  16         175  
866 16         329 push(@descriptions, process($text, $redirects));
867 16         76 %locals = (); # reset once per paragraph
868             }
869 5         40 return \@descriptions;
870             }
871              
872             =item normalize_elvish
873              
874             We do some post-processing of words, inspired by these two web pages, but using
875             our own replacements.
876             http://sindarinlessons.weebly.com/37---how-to-make-names-1.html
877             http://sindarinlessons.weebly.com/38---how-to-make-names-2.html
878              
879             =cut
880              
881             sub normalize_elvish {
882 6     6 1 13 my $original = shift;
883 6         8 my $name = $original;
884              
885 6         22 $name =~ s/(.) \1/$1/g;
886 6         15 $name =~ s/d t/d/g;
887 6         12 $name =~ s/a ui/au/g;
888 6         13 $name =~ s/nd m/dhm/g;
889 6         43 $name =~ s/n?d w/dhw/g;
890 6         17 $name =~ s/r gw/rw/g;
891 6         12 $name =~ s/^nd/d/;
892 6         10 $name =~ s/^ng/g/;
893 6         15 $name =~ s/th n?d/d/g;
894 6         10 $name =~ s/dh dr/dhr/g;
895 6         22 $name =~ s/ //g;
896              
897 6         16 $name =~ tr/âêîôûŷ/aeioúi/;
898 6         16 $name =~ s/ll$/l/;
899 6         10 $name =~ s/ben$/wen/g;
900 6         12 $name =~ s/bwi$/wi/;
901 6         10 $name =~ s/[^aeiouúi]ndil$/dil/g;
902 6         11 $name =~ s/ae/aë/g;
903 6         9 $name =~ s/ea/ëa/g;
904              
905 6         13 $name = ucfirst($name);
906              
907             # $log->debug("Elvish normalize: $original → $name");
908 6         13 return $name;
909             }
910              
911             =item process
912              
913             We do some post-processing after the description has been assembled: we move all
914             the IMG tags in a SPAN element with class "images". This makes it easier to lay
915             out the result using CSS.
916              
917             =cut
918              
919             sub process {
920 19     19 1 34 my $text = shift;
921 19         31 my $images = shift;
922 19 100       43 if ($images) {
923 10         99 $text =~ s/(]+?>)/$1<\/span>/g;
924             } else {
925 9         100 $text =~ s/(]+?>)//g;
926             }
927             # fix whilespace at the end of spans
928 19         339 $text =~ s/\s+<\/span>/<\/span> /g;
929             # strip empty paragraphs
930 19         88 $text =~ s/

\s*<\/p>//g;

931 19         78 $text =~ s/

\s*

/

/g;

932             # strip other empty elements
933 19         62 $text =~ s/<\/em>//g;
934 19         46 return $text;
935             }
936              
937             =item resolve_appends
938              
939             This removes text marked for appending and adds it at the end of a hex
940             description. This modifies the third parameter, C<$descriptions>.
941              
942             =cut
943              
944             sub resolve_appends {
945 1     1 1 1 my $map_data = shift;
946 1         4 my $table_data = shift;
947 1         2 my $descriptions = shift;
948 1         1 my $redirects = shift;
949 1         1 my $text;
950 1         3 for my $coord (keys %$descriptions) {
951 3         151 while ($descriptions->{$coord} =~ s/「append ([^][」]*)」/$text = $1; ""/e) {
  0         0  
  0         0  
952 0         0 $descriptions->{$coord} .= ' ' . $text;
953             }
954             }
955             }
956              
957             =item resolve_nearby
958              
959             We have nearly everything resolved except for references starting with the words
960             "closest" and "nearby" because these require all of the other data to be
961             present. This modifies the third parameter, C<$descriptions>.
962              
963             =cut
964              
965             sub resolve_nearby {
966 1     1 1 2 my $map_data = shift;
967 1         2 my $table_data = shift;
968 1         1 my $descriptions = shift;
969 1         2 my $redirects = shift;
970 1         4 for my $coord (keys %$descriptions) {
971             $descriptions->{$coord} =~
972 0 0       0 s/「(nearby|closest) ([^][」]*)」/closest($map_data,$table_data,$coord,$2,$1,$redirects) or '…'/ge
973 3         4500 for 1 .. 2; # two levels deep of 「nearby ...」
974 3         59 $descriptions->{$coord} =~ s!( \(\d+\))!$1!g; # fixup
975             }
976             }
977              
978             =item closest
979              
980             This picks the closest instance of whatever we're looking for. If the type
981             argument is "nearby", the same coordinates are excluded.
982              
983             =cut
984              
985             sub closest {
986 0     0 1 0 my $map_data = shift;
987 0         0 my $table_data = shift;
988 0         0 my $coordinates = shift;
989 0         0 my $key = shift;
990 0         0 my $type = shift;
991 0         0 my $redirects = shift;
992 0         0 my @coordinates = grep { $_ ne 'global' } keys %{$globals->{$key}};
  0         0  
  0         0  
993 0 0       0 @coordinates = grep { $_ ne $coordinates } @coordinates if $type eq "nearby";
  0         0  
994 0 0       0 if (not @coordinates) {
995 0         0 $log->info("Did not find any hex with $key ($coordinates)");
996 0         0 return "…";
997             }
998 0 0       0 if ($coordinates !~ /^\d+$/) {
999             # if $coordinates is "TOP" or "END" or something like that, we cannot get
1000             # the closest one and we need to return a random one
1001 0         0 my $random = one(@coordinates);
1002 0         0 return $globals->{$key}->{$random}
1003             . qq{ ($random)}; # see resolve_later!
1004             } else {
1005 0         0 @coordinates = sort { distance($coordinates, $a) <=> distance($coordinates, $b) } @coordinates;
  0         0  
1006             # the first one is the closest
1007 0         0 return $globals->{$key}->{$coordinates[0]}
1008             . qq{ ($coordinates[0])}; # see resolve_later!
1009             }
1010             }
1011              
1012             =item distance
1013              
1014             Returns the distance between two hexes. Either provide two coordinates (strings
1015             in the form "0101", "0102") or four numbers (1, 1, 1, 2).
1016              
1017             =cut
1018              
1019             sub distance {
1020 0     0 1 0 my ($x1, $y1, $x2, $y2) = @_;
1021 0 0       0 if (@_ == 2) {
1022 0         0 ($x1, $y1, $x2, $y2) = map { xy($_) } @_;
  0         0  
1023             }
1024             # transform the coordinate system into a decent system with one axis tilted by
1025             # 60°
1026 0         0 $y1 = $y1 - POSIX::ceil($x1/2);
1027 0         0 $y2 = $y2 - POSIX::ceil($x2/2);
1028 0 0       0 if ($x1 > $x2) {
1029             # only consider moves from left to right and transpose start and
1030             # end point to make it so
1031 0         0 my ($t1, $t2) = ($x1, $y1);
1032 0         0 ($x1, $y1) = ($x2, $y2);
1033 0         0 ($x2, $y2) = ($t1, $t2);
1034             }
1035 0 0       0 if ($y2>=$y1) {
1036             # if it the move has a downwards component add Δx and Δy
1037 0         0 return $x2-$x1 + $y2-$y1;
1038             } else {
1039             # else just take the larger of Δx and Δy
1040 0 0       0 return $x2-$x1 > $y1-$y2 ? $x2-$x1 : $y1-$y2;
1041             }
1042             }
1043              
1044             =item resolve_other
1045              
1046             This is a second phase. We have nearly everything resolved except for references
1047             starting with the word "other" because these require all of the other data to
1048             be present. This modifies the third parameter, C<$descriptions>.
1049              
1050             =cut
1051              
1052             sub resolve_other {
1053 1     1 1 2 my $map_data = shift;
1054 1         2 my $table_data = shift;
1055 1         2 my $descriptions = shift;
1056 1         2 my $redirects = shift;
1057 1         3 for my $coord (keys %$descriptions) {
1058 3         170 $descriptions->{$coord} =~
1059 0 0       0 s/「other ([^][」]*)」/some_other($map_data,$table_data,$coord,$1, $redirects) or '…'/ge;
1060 3         55 $descriptions->{$coord} =~ s!( \(\d+\))!$1!g; # fixup
1061             }
1062             }
1063              
1064             =item some_other
1065              
1066             This picks some other instance of whatever we're looking for, irrespective of distance.
1067              
1068             =cut
1069              
1070             sub some_other {
1071 0     0 1 0 my $map_data = shift;
1072 0         0 my $table_data = shift;
1073 0         0 my $coordinates = shift;
1074 0         0 my $key = shift;
1075 0         0 my $redirects = shift;
1076             # make sure we don't pick the same location!
1077 0         0 my @coordinates = grep !/$coordinates/, keys %{$globals->{$key}};
  0         0  
1078 0 0       0 if (not @coordinates) {
1079 0         0 $log->info("Did not find any other hex with $key");
1080 0         0 return "…";
1081             }
1082             # just pick a random one
1083 0         0 my $other = one(@coordinates);
1084 0         0 return $globals->{$key}->{$other}
1085             . qq{ ($other)}; # see resolve_later!
1086             }
1087              
1088              
1089             =item resolve_later
1090              
1091             This is a second phase. We have nearly everything resolved except for references
1092             starting with the word "later" because these require all of the other data to be
1093             present. This modifies the third parameter, C<$descriptions>. Use this for
1094             recursive lookup involving "nearby" and "other".
1095              
1096             This also takes care of hex references introduced by "nearby" and "other". This
1097             is also why we need to take extra care to call C on various strings
1098             we want to search and replace: these hex references contain parenthesis!
1099              
1100             =cut
1101              
1102             sub resolve_later {
1103 1     1 1 2 my $map_data = shift;
1104 1         3 my $table_data = shift;
1105 1         2 my $descriptions = shift;
1106 1         2 my $redirects = shift;
1107 1         2 for my $coord (keys %$descriptions) {
1108 3         193 while ($descriptions->{$coord} =~ /「later ([^][」]*)」/) {
1109 0         0 my $words = $1;
1110 0         0 my ($ref) = $words =~ m!( \(.*\))!;
1111 0   0     0 $ref //= ''; # but why should it ever be empty?
1112 0         0 my $key = $words;
1113 0         0 my $re = quotemeta($ref);
1114 0 0       0 $key =~ s/$re// if $ref;
1115 0         0 $re = quotemeta($words);
1116 0         0 my $result = $descriptions->{$coord} =~
1117 0 0       0 s/「later $re」/describe($map_data,$table_data,1,$coord,[$key], $redirects) . $ref or '…'/ge;
1118 0 0       0 if (not $result) {
1119 0         0 $log->error("Could not resolve later reference in '$words'");
1120 0         0 last; # avoid infinite loops!
1121             }
1122             }
1123             }
1124             }
1125              
1126             =item describe_map
1127              
1128             This is one of the top entry points: it simply calls C for every hex
1129             in C<$map_data> and calls C on the result. All the texts are collected
1130             into a new hash where the hex coordinates are the key and the generated
1131             description is the value.
1132              
1133             =cut
1134              
1135             sub describe_map {
1136 1     1 1 3 my $map_data = shift;
1137 1         2 my $table_data = shift;
1138 1         2 my $redirects = shift;
1139 1         2 my %descriptions;
1140             # first, add special rule for TOP and END keys which the description template knows
1141 1         2 for my $coords (qw(TOP END)) {
1142             # with redirects means we keep images
1143 2         10 my $description =
1144             process(describe($map_data, $table_data, 1,
1145             $coords, [$coords], $redirects), $redirects);
1146             # only set the TOP and END key if there is a description
1147 2 50       10 $descriptions{$coords} = $description if $description;
1148             }
1149             # shuffle sort the coordinates so that it is reproducibly random
1150 1         23 for my $coord (shuffle sort keys %$map_data) {
1151             # with redirects means we keep images
1152             my $description =
1153             process(describe($map_data, $table_data, 1,
1154 1         5 $coord, $map_data->{$coord}, $redirects), $redirects);
1155             # only set the description if there is one (empty hexes are not listed)
1156 1 50       6 $descriptions{$coord} = $description if $description;
1157             }
1158 1         4 resolve_nearby($map_data, $table_data, \%descriptions, $redirects);
1159 1         6 resolve_other($map_data, $table_data, \%descriptions, $redirects);
1160 1         5 resolve_later($map_data, $table_data, \%descriptions, $redirects);
1161             # as append might include the items above, it must come last
1162 1         5 resolve_appends($map_data, $table_data, \%descriptions, $redirects);
1163 1         7 return \%descriptions;
1164             }
1165              
1166             =item add_labels
1167              
1168             This function is used after generating the descriptions to add the new names of
1169             rivers and trails to the existing map.
1170              
1171             =cut
1172              
1173             sub add_labels {
1174 0     0 1 0 my $map = shift;
1175 0         0 $map =~ s/$line_re/get_label($1,$2)/ge;
  0         0  
1176 0         0 return $map;
1177             }
1178              
1179             =item get_label
1180              
1181             This function returns the name of a line.
1182              
1183             =cut
1184              
1185             sub get_label {
1186 0     0 1 0 my $map_line = shift;
1187 0         0 my $type = shift;
1188 0         0 my @hexes = split(/-/, $map_line);
1189             LINE:
1190 0         0 for my $line (@{$extra->{$hexes[0]}}) {
  0         0  
1191 0 0       0 next unless $line->{type} eq $type;
1192 0         0 for my $hex (@hexes) {
1193 0         0 my @line = @{$line->{line}};
  0         0  
1194 0 0       0 next LINE unless grep(/$hex/, @line);
1195             }
1196 0         0 my $label = $line->{name};
1197 0         0 return qq{$map_line $type "$label"};
1198             }
1199 0         0 return qq{$map_line $type};
1200             }
1201              
1202             =item xy
1203              
1204             This is a helper function to turn "0101" into ("01", "01") which is equivalent
1205             to (1, 1).
1206              
1207             =cut
1208              
1209             sub xy {
1210 0     0 1 0 my $coordinates = shift;
1211 0         0 return (substr($coordinates, 0, 2), substr($coordinates, 2));
1212             }
1213              
1214             =item coordinates
1215              
1216             This is a helper function to turn (1, 1) back into "0101".
1217              
1218             =cut
1219              
1220             sub coordinates {
1221 0     0 1 0 my ($x, $y) = @_;
1222 0         0 return sprintf("%02d%02d", $x, $y);
1223             }
1224              
1225             =item neighbour
1226              
1227             This is a helper function that takes the coordinates of a hex, a reference like
1228             [1,1] or regular coordinates like "0101", and a direction from 0 to 5, and
1229             returns the coordinates of the neighbouring hex in that direction.
1230              
1231             =cut
1232              
1233             my $delta = [[[-1, 0], [ 0, -1], [+1, 0], [+1, +1], [ 0, +1], [-1, +1]], # x is even
1234             [[-1, -1], [ 0, -1], [+1, -1], [+1, 0], [ 0, +1], [-1, 0]]]; # x is odd
1235              
1236             sub neighbour {
1237             # $hex is [x,y] or "0101" and $i is a number 0 .. 5
1238 0     0 1 0 my ($hex, $i) = @_;
1239 0 0       0 $hex = [xy($hex)] unless ref $hex;
1240             # return is a string like "0102"
1241 0         0 return coordinates(
1242             $hex->[0] + $delta->[$hex->[0] % 2]->[$i]->[0],
1243             $hex->[1] + $delta->[$hex->[0] % 2]->[$i]->[1]);
1244             }
1245              
1246             =item neighbours
1247              
1248             This is a helper function that takes map_data and the coordinates of a hex, a
1249             reference like [1,1] or regular coordinates like "0101", and returns a list of
1250             existing neighbours, or the string "[…]". This makes a difference at the edge of
1251             the map.
1252              
1253             =cut
1254              
1255             sub neighbours {
1256 0     0 1 0 my $map_data = shift;
1257 0         0 my $hex = shift;
1258 0         0 my @neighbours;
1259 0 0       0 $hex = [xy($hex)] unless ref $hex;
1260 0         0 for my $i (0 .. 5) {
1261 0         0 my $neighbour = neighbour($hex, $i);
1262             # $log->debug($neighbour);
1263 0 0       0 push(@neighbours, $neighbour) if $map_data->{$neighbour};
1264             }
1265 0 0       0 return "..." unless @neighbours;
1266 0         0 return @neighbours;
1267             }
1268              
1269             =item one
1270              
1271             This is a helper function that picks a random element from a list. This works
1272             both for actual lists and for references to lists.
1273              
1274             =cut
1275              
1276             sub one {
1277 0     0 1 0 my @arr = @_;
1278 0 0 0     0 @arr = @{$arr[0]} if @arr == 1 and ref $arr[0] eq 'ARRAY';
  0         0  
1279 0         0 return $arr[int(rand(scalar @arr))];
1280             }
1281              
1282             =item one_step_to
1283              
1284             Given a hex to start from, check all directions and figure out which neighbour
1285             is closer to your destination. Return the coordinates of this neighbour.
1286              
1287             =cut
1288              
1289             sub one_step_to {
1290 0     0 1 0 my $from = shift;
1291 0         0 my $to = shift;
1292 0         0 my ($min, $best);
1293 0         0 for my $i (0 .. 5) {
1294             # make a new guess
1295 0         0 my ($x, $y) = ($from->[0] + $delta->[$from->[0] % 2]->[$i]->[0],
1296             $from->[1] + $delta->[$from->[0] % 2]->[$i]->[1]);
1297 0         0 my $d = ($to->[0] - $x) * ($to->[0] - $x)
1298             + ($to->[1] - $y) * ($to->[1] - $y);
1299 0 0 0     0 if (!defined($min) || $d < $min) {
1300 0         0 $min = $d;
1301 0         0 $best = [$x, $y];
1302             }
1303             }
1304 0         0 return $best;
1305             }
1306              
1307             =item compute_missing_points
1308              
1309             Return a list of coordinates in string form. Thus, given a list like ("0302",
1310             "0101") it will return ("0302", "0201", "0101").
1311              
1312             =cut
1313              
1314             sub compute_missing_points {
1315 0     0 1 0 my @result = ($_[0]); # "0101" not [01,02]
1316 0         0 my @points = map { [xy($_)] } @_;
  0         0  
1317             # $log->debug("Line: " . join(", ", map { coordinates(@$_) } @points));
1318 0         0 my $from = shift(@points);
1319 0         0 while (@points) {
1320             # $log->debug("Going from " . coordinates(@$from) . " to " . coordinates(@{$points[0]}));
1321 0         0 $from = one_step_to($from, $points[0]);
1322 0 0 0     0 shift(@points) if $from->[0] == $points[0]->[0] and $from->[1] == $points[0]->[1];
1323 0         0 push(@result, coordinates(@$from));
1324             }
1325 0         0 return @result;
1326             }
1327              
1328             =item same_direction
1329              
1330             Given two linear structures and a point of contact, return 1 if the these
1331             objects go in the same direction on way or the other.
1332              
1333             =cut
1334              
1335             sub same_direction {
1336 0     0 1 0 my $coord = shift;
1337 0         0 my $line1 = shift;
1338 0         0 my $line2 = shift;
1339             # $log->debug("same_direction: $coord, @$line1 and @$line2");
1340             # this code assumes that a line starts with $type at index 0
1341 0         0 my $j;
1342 0         0 for my $i (1 .. $#$line1) {
1343 0 0       0 if ($line1->[$i] eq $coord) {
1344 0         0 $j = $i;
1345 0         0 last;
1346             }
1347             }
1348             # $log->debug("same_direction: $coord has index $j in @$line1");
1349 0         0 for my $i1 ($j - 1, $j + 1) {
1350 0 0 0     0 next if $i1 == 0 or $i1 > $#$line1;
1351 0         0 my $next = $line1->[$i1];
1352 0         0 for my $i2 (1 .. $#$line2) {
1353 0 0       0 if ($line2->[$i2] eq $coord) {
1354 0 0 0     0 if ($line2->[$i2-1] and $next eq $line2->[$i2-1]
      0        
      0        
1355             or $line2->[$i2+1] and $next eq $line2->[$i2+1]) {
1356             # $log->debug("same direction at $coord: @$line1 and @$line2");
1357 0         0 return 1;
1358             }
1359             }
1360             }
1361             }
1362 0         0 return 0;
1363             }
1364              
1365             =item spread_name
1366              
1367             This function is used to spread a name along terrain features.
1368              
1369             =cut
1370              
1371             sub spread_name {
1372 0     0 1 0 my $map_data = shift;
1373 0         0 my $coordinates = shift;
1374 0         0 my $word = shift; # "name for white big mountain"
1375 0         0 my $key = shift; # "white"
1376 0         0 my @keys = split(/\//, $key); # ("white")
1377 0         0 my $name = shift; # "Vesuv"
1378 0         0 my %seen = ($coordinates => 1);
1379 0         0 $globals->{$key}->{$coordinates} = $name;
1380             # $log->debug("$word: $coordinates = $name");
1381 0         0 my @queue = map { neighbour($coordinates, $_) } 0..5;
  0         0  
1382 0         0 while (@queue) {
1383             # $log->debug("Working on the first item of @queue");
1384 0         0 my $coord = shift(@queue);
1385 0 0 0     0 next if $seen{$coord} or not $map_data->{$coord};
1386 0         0 $seen{$coord} = 1;
1387 0 0       0 if (intersect(@keys, @{$map_data->{$coord}})) {
  0         0  
1388             $log->error("$word for $coord is already something else")
1389 0 0       0 if $names{"$word for $coord"};
1390 0         0 $names{"$word: $coord"} = $name; # "name for white big mountain: 0102"
1391             # $log->debug("$coord: $name for @keys");
1392 0         0 $globals->{$_}->{$coord} = $name for @keys;
1393             # $log->debug("$word: $coord = $name");
1394 0         0 push(@queue, map { neighbour($coord, $_) } 0..5);
  0         0  
1395             }
1396             }
1397             }
1398              
1399             =item markdown
1400              
1401             This allows us to generate Markdown output.
1402              
1403             =cut
1404              
1405             sub markdown {
1406 5     5 1 10 my $descriptions = shift;
1407 5   100     28 my $separator = shift || "\n\n---\n\n";
1408             my @paragraphs = map {
1409             # remove inline images
1410 5         18 s!]*>!!g;
  9         104  
1411             # empty spans left after img has been removed
1412 9         48 s!]*>\s*!!g;
1413             # remaining spans result in Japanese brackets around their text
1414 9         427 s!]*>\s*!「!g;
1415 9         96 s!\s*!」!g;
1416             # emphasis
1417 9         601 s!!**!g;
1418 9         229 s!!*!g;
1419 9         37 s!!_!g;
1420             # remove links but leave their text
1421 9         725 s!]*>!!g;
1422             # closing paragraph tags are optional
1423 9         33 s!

!!g;
1424             # paragraph breaks
1425 9         334 s!!\n\n!g;
1426             # blockquotes
1427 9         57 s!
(.*?)
!local $_ = $1; s/^/\n> /g; $_!gem;
  0         0  
  0         0  
  0         0  
1428             # unreplaced references (nearby, other, later)
1429 9         21 s!(.*?)!$1!g;
1430             # return what's left
1431 9         25 markdown_lists($_);
1432             } @$descriptions;
1433 5         206 return join($separator, @paragraphs);
1434             }
1435              
1436             sub markdown_lists {
1437 9     9 0 15 $_ = shift;
1438 9         23 my ($str, @list);
1439 9         218 for (split(/(<.*?>)/)) {
1440 128 50       323 if (/^$/) { unshift(@list, '1.'); $str .= "\n" }
  0 50       0  
  0 50       0  
    50          
    50          
1441 0         0 elsif (/^$/) { unshift(@list, '*'); $str .= "\n" }
  0         0  
1442             elsif (/^
  • $/) {
  • 1443             $str .= (@list > 1
    1444             # all the list markers except for the current one
    1445 0 0       0 ? (" " x (sum map { length($_) + 1 } @list[1..$#list]))
      0         0  
    1446             : "")
    1447             . $list[0] . " " }
    1448 0         0 elsif (/^<\/(ol|ul)>$/) { shift(@list) }
    1449 0 0       0 elsif (/^<\/li>$/) { $str .= "\n" unless $str =~ /\n$/ }
    1450 128         278 else { $str .= $_ }
    1451             }
    1452 9         163 return $str;
    1453             }
    1454              
    1455             1;