File Coverage

blib/lib/Game/HexDescribe/Utils.pm
Criterion Covered Total %
statement 331 691 47.9
branch 103 334 30.8
condition 54 161 33.5
subroutine 33 49 67.3
pod 38 40 95.0
total 559 1275 43.8


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   4489 use Text::Autoformat;
  6         163331  
  6         606  
37 6     6   3682 use Game::HexDescribe::Log;
  6         20  
  6         230  
38 6     6   43 use Modern::Perl;
  6         81  
  6         70  
39 6     6   2572 use Mojo::URL;
  6         14  
  6         68  
40 6     6   191 use Mojo::File;
  6         10  
  6         351  
41 6     6   55 use List::Util qw(shuffle sum);
  6         17  
  6         665  
42 6     6   3318 use Array::Utils qw(intersect);
  6         3172  
  6         482  
43 6     6   50 use Encode qw(decode_utf8);
  6         12  
  6         327  
44 6     6   38 use utf8;
  6         11  
  6         51  
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 630 my ($name, $dir) = @_;
74 7         449 $log->debug("Looking for table '$name' in the contrib directory '$dir'");
75 7         319 my $file = Mojo::File->new("$dir/hex-describe-$name-table.txt");
76 7 100       126 return decode_utf8($file->slurp) if -e $file;
77 4         291 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 402 my ($name, $dir) = @_;
89 1         8 $log->debug("Looking for map in the contrib directory: $dir");
90 1         16 my $file = Mojo::File->new("$dir/hex-describe-$name-map.txt");
91 1 50       12 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 23 my $text = shift;
126 7         903 $log->debug("parse_table: parsing " . length($text) . " characters");
127 7         97 my $data = {};
128 7         21 my $words = "[^\[\]\n]*";
129 7         57 my (%aliases, $key, $c, $t);
130 7         171867 for my $line (split(/\r?\n/, $text)) {
131 122163 100 100     758375 if ($line =~ /^;([^#\r\n]+)/) {
    100 66        
    50          
132 6237         13359 $key = $1;
133 6237 50       17268 $log->warn("parse_table: reset '$key'") if exists $data->{$key};
134 6237         29219 $data->{$key} = {}; # reset, don't merge
135             } elsif ($key and ($c, $t) = $line =~ /^(\d+),(.*)/) {
136 108564         203049 $t =~ s/\*\*(.*?)\*\*/$1<\/strong>/g;
137 108564         179363 $t =~ s/\*(.*?)\*/$1<\/em>/g;
138 108564         256059 my %h = (text => $t);
139 108564 100       219124 if ($c == 0) {
140 18         61 $h{unique} = 1;
141 18         34 $c = 1;
142             }
143 108564         197041 $h{count} = $c;
144 108564         179678 $data->{$key}->{total} += $c;
145 108564         138253 push(@{$data->{$key}->{lines}}, \%h);
  108564         260751  
146             # [foo as bar]
147 108564         454122 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 108564         413449 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         14249 for my $table (keys %$data) {
160 6237         9494 for my $line (@{$data->{$table}->{lines}}) {
  6237         22651  
161 108564         370612 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|neighbouring|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         3883 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 21 %names = ();
251 6         16 %locals = ();
252 6         16 $globals = undef;
253 6         14 $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         3 my $map_data;
268 1 50 33     25 if ($map and $map->isa('Mojo::Upload')) {
269 0         0 $map = $map->slurp();
270             };
271 1         17 for my $hex (split(/\r?\n/, $map)) {
272 1 50       19 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         3 my @types = ("system"); # Traveller
276 1   66     13 while($hex =~ /\G([a-z]="[^"]+")\s*/cg or $hex =~ /(\S+)/cg) {
277 1         8 push(@types, $1);
278             }
279 1         8 $map_data->{"$x$y"} = \@types;
280             }
281             }
282 1         4 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 2 my $map = shift;
300 1         3 my @lines;
301 1         18 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         3 my $lines = shift;
344 1         4 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 21 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         3 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 4 my $map = shift;
442 1         4 my $map_data = parse_map_data($map);
443 1         5 my $lines = parse_map_lines($map);
444             # longest rivers first
445 1         4 @$lines = sort { @$b <=> @$a } @$lines;
  0         0  
446             # for my $line (@$lines) {
447             # $log->debug("@$line");
448             # }
449 1         7 process_map_merge_lines($map_data, $lines);
450 1         16 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         5 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 340     340 1 548 my $h = shift;
470 340         986 my $total = $h->{total};
471 340         649 my $lines = $h->{lines};
472 340         1043 my $roll = int(rand($total)) + 1;
473 340         584 my $i = 0;
474 340         721 for my $line (@$lines) {
475 13805         26582 $i += $line->{count};
476 13805 100       30721 if ($i >= $roll) {
477 340 50       887 if ($line->{unique}) {
478 0         0 $h->{total} -= $line->{count};
479 0         0 $line->{count} = 0;
480             }
481 340         1270 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 23 my $url = shift;
502 10         18 my $redirects = shift;
503 10 50 33     138 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 341     341 1 626 my $map_data = shift;
553 341         541 my $table_data = shift;
554 341         525 my $level = shift;
555 341         668 my $coordinates = shift;
556 341         564 my $words = shift;
557 341         726 my $word = shift;
558 341         521 my $redirects = shift;
559 341         513 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 341         817 for my $context (grep( { $_ ne $word } @$words), $word) {
  343         1434  
564 353 100       941 my $key = ($context eq $word ? $word : "$context $word");
565             # $log->debug("$coordinates: looking for a $key table") if $coordinates eq "0109";
566 353 100       1513 if ($table_data->{$key}) {
567 340         939 $text = pick_description($table_data->{$key});
568             # $log->debug("$coordinates → $key → $text");
569 340         882 my $seed = int(rand(~0)); # maxint
570 340         887 $text =~ s/\[\[redirect (https:.*?)\]\]/my $url = $1; $url =~ s!\$seed!$seed!; resolve_redirect($url, $redirects)/ge;
  10         31  
  10         63  
  10         77  
571             # this makes sure we recursively resolve all references, in order, because
572             # we keep rescanning from the beginning
573 340         808 my $last = $text;
574 340         1807 while ($text =~ s/\[([^][]*)\]/describe($map_data,$table_data,$level+1,$coordinates,[$1], $redirects)/e) {
  480         2531  
575 480 50       1684 if ($last eq $text) {
576 0         0 $log->error("Infinite loop: $text");
577 0         0 last;
578             }
579 480         3456 $last = $text;
580             };
581 340         804 last;
582             }
583             }
584             # $log->debug("$word → $text ") if $text;
585 341         1023 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 499     499 1 920 my $map_data = shift;
600 499         892 my $table_data = shift;
601 499         742 my $level = shift;
602 499         859 my $coordinates = shift;
603 499         850 my $words = shift;
604 499         845 my $redirects = shift;
605 499 50       1451 $log->error("Recursion level $level exceeds 20 in $coordinates (@$words)!") if $level > 20;
606 499 50       1134 return '' if $level > 20;
607 499 100       1379 if ($level == 1) {
608 3         57 %locals = (hex => $coordinates); # reset once per paragraph
609 3         11 for my $word (@$words) {
610 4 50 33     34 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         18 $locals{$word} = 1;
617             }
618             }
619             }
620 499         817 my @descriptions;
621 499         1039 for my $word (@$words) {
622             # valid dice rolls: 1d6, 1d6+1, 1d6x10, 1d6x10+1
623 500 100 0     18256 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          
    50          
    50          
    50          
    100          
    50          
    0          
    0          
624 39         83 my $r = 0;
625 39 50       95 if ($c) {
626 0         0 $r = $c;
627             } else {
628 39         146 for(my $i = 0; $i < $n; $i++) {
629 51         270 $r += int(rand($d)) + 1;
630             }
631 39   100     210 $r *= $m||1;
632 39   100     142 $r += $p||0;
633 39 50 33     124 $r = $min if defined $min and $r < $min;
634 39 50 33     128 $r = $max if defined $max and $r > $max;
635             }
636             # $log->debug("rolling dice: $word = $r");
637 39 100       111 $locals{$save_as} = $r if $save_as;
638 39 50       163 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         27 my $name = $names{$word};
665             # $log->debug("memoized: $word is $name") if $name;
666 7 100       28 return $name if $name;
667 6         90 $name = pick($map_data, $table_data, $level, $coordinates, $words, $word, $redirects);
668 6 50       23 next unless $name;
669 6         27 $names{$word} = $name;
670             # $log->debug("$word is $name");
671 6         21 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|neighbouring) ./) {
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 91         411 my $key = $1;
748 91 50 33     589 return $locals{$key}->[0] if exists $locals{$key} and ref($locals{$key}) eq 'ARRAY';
749 91 50       815 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         8 my ($where, $key, $alias) = ($1, $2, $3);
755 2         8 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
756 2 50       7 next unless $text;
757 2         8 $locals{$key} = [$text]; # start a new list
758 2 50       9 $locals{$alias} = $text if $alias;
759 2 50 33     8 $globals->{$key}->{$coordinates} = $text if $where and $where eq 'here';
760 2 0 33     9 $globals->{$alias}->{$coordinates} = $text if $where and $where eq 'here' and $alias;
      33        
761 2 50 33     8 $globals->{$key}->{global} = $text if $where and $where eq 'global';
762 2 0 33     22 $globals->{$alias}->{global} = $text if $where and $where eq 'global' and $alias;
      33        
763 2         10 push(@descriptions, $text);
764             } elsif ($word =~ /^(?:(here|global) )?and (.+?)(?: as (.+))?$/) {
765 2         84 my ($where, $key, $alias) = ($1, $2, $3);
766 2         6 my $found = 0;
767             # limited attempts to find a unique entry for an existing list (instead of
768             # modifying the data structures)
769 2         10 for (1 .. 20) {
770 4         15 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
771 4 50       18 $log->warn("[and $key] is used before [with $key] is done in $coordinates") if ref $locals{$key} ne 'ARRAY';
772 4 50       14 $locals{$key} = [$text] if ref $locals{$key} ne 'ARRAY';
773 4 100 66     15 next if not $text or grep { $text eq $_ } @{$locals{$key}};
  4         16  
  4         14  
774 2         50 push(@{$locals{$key}}, $text);
  2         8  
775 2         5 push(@descriptions, $text);
776 2 50       9 $locals{$alias} = $text if $alias;
777 2 50 33     8 $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     8 $globals->{$key}->{global} = $text if $where and $where eq 'global';
780 2 0 33     8 $globals->{$alias}->{global} = $text if $where and $where eq 'global' and $alias;
      33        
781 2         7 $found = 1;
782 2         5 last;
783             }
784 2 50       9 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 0         0 my $key = $1;
790 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
791 0 0       0 next unless $text;
792 0         0 $locals{$key} = $text;
793 0         0 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 1         5 my $key = $1;
808 1         5 my $text = normalize_elvish($key);
809 1 50       5 next unless $text;
810 1         5 $locals{$key} = $text;
811 1         4 push(@descriptions, $text);
812             } elsif ($word =~ /^(?:(here|global) )?(?:(save|store|quote) )?(.+?)(?: as (.+))?$/) {
813 358         1913 my ($where, $action, $key, $alias) = ($1, $2, $3, $4);
814 358         641 my $text;
815 358 100 100     1182 if (not $action or $action eq "save") {
816             # no action and save are with lookup
817 329         1011 $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
818             } else {
819             # quote and store are without lookup
820 29         62 $text = $key;
821             }
822 358 100       1000 next unless $text;
823 275         804 $locals{$key} = $text;
824 275 100       670 $locals{$alias} = $text if $alias;
825 275 100 100     715 $globals->{$key}->{$coordinates} = $text if $where and $where eq 'here';
826 275 100 100     708 $globals->{$alias}->{$coordinates} = $text if $where and $where eq 'here' and $alias;
      66        
827 275 100 100     633 $globals->{$key}->{global} = $text if $where and $where eq 'global';
828 275 100 100     681 $globals->{$alias}->{global} = $text if $where and $where eq 'global' and $alias;
      66        
829 275 100 66     1229 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 407         3545 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 17 my $input = shift;
859 5         13 my $table_data = shift;
860 5         12 my $redirects = shift;
861 5         41 my @descriptions;
862 5         30 init();
863 5         115 for my $text (split(/\r?\n/, $input)) {
864             # recusion level 2 makes sure we don't reset %locals
865 16         1946 $text =~ s/\[(.*?)\]/describe({},$table_data,2,"no map",[$1],$redirects)/ge;
  16         118  
866 16         94 push(@descriptions, process($text, $redirects));
867 16         160 %locals = (); # reset once per paragraph
868             }
869 5         57 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 1     1 1 3 my $original = shift;
883 1         2 my $name = $original;
884              
885 1         8 $name =~ s/(.) \1/$1/g;
886 1         4 $name =~ s/d t/d/g;
887 1         5 $name =~ s/a ui/au/g;
888 1         4 $name =~ s/nd m/dhm/g;
889 1         5 $name =~ s/n?d w/dhw/g;
890 1         5 $name =~ s/r gw/rw/g;
891 1         4 $name =~ s/^nd/d/;
892 1         6 $name =~ s/^ng/g/;
893 1         8 $name =~ s/th n?d/d/g;
894 1         4 $name =~ s/dh dr/dhr/g;
895 1         6 $name =~ s/ //g;
896              
897 1         4 $name =~ tr/âêîôûŷ/aeioúi/;
898 1         4 $name =~ s/ll$/l/;
899 1         5 $name =~ s/ben$/wen/g;
900 1         4 $name =~ s/bwi$/wi/;
901 1         4 $name =~ s/[^aeiouúi]ndil$/dil/g;
902 1         4 $name =~ s/ae/aë/g;
903 1         3 $name =~ s/ea/ëa/g;
904              
905 1         5 $name = ucfirst($name);
906              
907             # $log->debug("Elvish normalize: $original → $name");
908 1         3 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 51 my $text = shift;
921 19         35 my $images = shift;
922 19 100       59 if ($images) {
923 10         158 $text =~ s/(]+?>)/$1<\/span>/g;
924             } else {
925 9         111 $text =~ s/(]+?>)//g;
926             }
927             # fix whilespace at the end of spans
928 19         396 $text =~ s/\s+<\/span>/<\/span> /g;
929             # strip empty paragraphs
930 19         95 $text =~ s/

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

931 19         93 $text =~ s/

\s*

/

/g;

932             # strip other empty elements
933 19         83 $text =~ s/<\/em>//g;
934 19         67 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 2 my $map_data = shift;
946 1         3 my $table_data = shift;
947 1         2 my $descriptions = shift;
948 1         3 my $redirects = shift;
949 1         3 my $text;
950 1         5 for my $coord (keys %$descriptions) {
951 3         177 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 3 my $map_data = shift;
967 1         18 my $table_data = shift;
968 1         4 my $descriptions = shift;
969 1         3 my $redirects = shift;
970 1         7 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         8846 for 1 .. 2; # two levels deep of 「nearby ...」
974 3         90 $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 4 my $map_data = shift;
1054 1         61 my $table_data = shift;
1055 1         4 my $descriptions = shift;
1056 1         3 my $redirects = shift;
1057 1         6 for my $coord (keys %$descriptions) {
1058 3         228 $descriptions->{$coord} =~
1059 0 0       0 s/「other ([^][」]*)」/some_other($map_data,$table_data,$coord,$1, $redirects) or '…'/ge;
1060 3         77 $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             =item resolve_neighbouring
1089              
1090             This implements "neighbouring [foo] ? [bar] : [baz]". Both the then and the else
1091             clause are optional.
1092              
1093             =cut
1094              
1095             sub resolve_neighbouring {
1096 1     1 1 3 my $map_data = shift;
1097 1         5 my $table_data = shift;
1098 1         3 my $descriptions = shift;
1099 1         2 my $redirects = shift;
1100 1         5 for my $coord (keys %$descriptions) {
1101             $descriptions->{$coord} =~
1102 0 0       0 s/「neighbouring ([^][」]*?)(?: \? ([^][」]*?))?(?: : ([^][」]*?))?」/neighbouring($map_data,$table_data,$coord,$1,$2,$3) or '…'/ge
1103 3         263 for 1 .. 2; # two levels deep of 「neighbouring ...」
1104             }
1105             }
1106              
1107             =item some_other
1108              
1109             This implements if-then-else logic where "if" must be a global entry in the
1110             neighbouring hexes. If no "then" clause is supplied, the neighbouring entry is
1111             returned if any.
1112              
1113             =cut
1114              
1115             sub neighbouring {
1116 0     0 0 0 my $map_data = shift;
1117 0         0 my $table_data = shift;
1118 0         0 my $coordinates = shift;
1119 0         0 my $if = shift;
1120 0         0 my $then = shift;
1121 0         0 my $else = shift;
1122 0         0 my @neighbours = neighbours($map_data, $coordinates);
1123             # $log->info("neighbours of $coordinates: @neighbours");
1124 0         0 my @coordinates = keys %{$globals->{$if}};
  0         0  
1125             # $log->info("coordinates with '$if': @coordinates");
1126 0         0 @coordinates = intersect(@coordinates, @neighbours);
1127             # $log->info("intersection: @coordinates");
1128             # $log->info("if '@coordinates' then '$then' else '$else'");
1129 0 0       0 if (@coordinates) {
1130 0 0       0 return $then if $then;
1131             # just pick a random one
1132 0         0 my $other = one(@coordinates);
1133 0         0 return $globals->{$if}->{$other}
1134             . qq{ ($other)}; # see resolve_later!
1135             } else {
1136 0 0       0 return $else if $else;
1137             }
1138             }
1139              
1140             =item resolve_later
1141              
1142             This is a second phase. We have nearly everything resolved except for references
1143             starting with the word "later" because these require all of the other data to be
1144             present. This modifies the third parameter, C<$descriptions>. Use this for
1145             recursive lookup involving "nearby" and "other".
1146              
1147             This also takes care of hex references introduced by "nearby" and "other". This
1148             is also why we need to take extra care to call C on various strings
1149             we want to search and replace: these hex references contain parenthesis!
1150              
1151             =cut
1152              
1153             sub resolve_later {
1154 1     1 1 3 my $map_data = shift;
1155 1         5 my $table_data = shift;
1156 1         3 my $descriptions = shift;
1157 1         3 my $redirects = shift;
1158 1         4 for my $coord (keys %$descriptions) {
1159 3         202 while ($descriptions->{$coord} =~ /「later ([^][」]*)」/) {
1160 0         0 my $words = $1;
1161 0         0 my ($ref) = $words =~ m!( \(.*\))!;
1162 0   0     0 $ref //= ''; # but why should it ever be empty?
1163 0         0 my $key = $words;
1164 0         0 my $re = quotemeta($ref);
1165 0 0       0 $key =~ s/$re// if $ref;
1166 0         0 $re = quotemeta($words);
1167 0         0 my $result = $descriptions->{$coord} =~
1168 0 0       0 s/「later $re」/describe($map_data,$table_data,1,$coord,[$key], $redirects) . $ref or '…'/ge;
1169 0 0       0 if (not $result) {
1170 0         0 $log->error("Could not resolve later reference in '$words'");
1171 0         0 last; # avoid infinite loops!
1172             }
1173             }
1174             }
1175             }
1176              
1177             =item describe_map
1178              
1179             This is one of the top entry points: it simply calls C for every hex
1180             in C<$map_data> and calls C on the result. All the texts are collected
1181             into a new hash where the hex coordinates are the key and the generated
1182             description is the value.
1183              
1184             =cut
1185              
1186             sub describe_map {
1187 1     1 1 5 my $map_data = shift;
1188 1         2 my $table_data = shift;
1189 1         3 my $redirects = shift;
1190 1         3 my %descriptions;
1191             # first, add special rule for TOP and END keys which the description template knows
1192 1         3 for my $coords (qw(TOP END)) {
1193             # with redirects means we keep images
1194 2         30 my $description =
1195             process(describe($map_data, $table_data, 1,
1196             $coords, [$coords], $redirects), $redirects);
1197             # only set the TOP and END key if there is a description
1198 2 50       16 $descriptions{$coords} = $description if $description;
1199             }
1200             # shuffle sort the coordinates so that it is reproducibly random
1201 1         31 for my $coord (shuffle sort keys %$map_data) {
1202             # with redirects means we keep images
1203             my $description =
1204             process(describe($map_data, $table_data, 1,
1205 1         10 $coord, $map_data->{$coord}, $redirects), $redirects);
1206             # only set the description if there is one (empty hexes are not listed)
1207 1 50       9 $descriptions{$coord} = $description if $description;
1208             }
1209 1         7 resolve_nearby($map_data, $table_data, \%descriptions, $redirects);
1210 1         10 resolve_other($map_data, $table_data, \%descriptions, $redirects);
1211 1         8 resolve_neighbouring($map_data, $table_data, \%descriptions, $redirects);
1212 1         8 resolve_later($map_data, $table_data, \%descriptions, $redirects);
1213             # as append might include the items above, it must come last
1214 1         8 resolve_appends($map_data, $table_data, \%descriptions, $redirects);
1215 1         9 return \%descriptions;
1216             }
1217              
1218             =item add_labels
1219              
1220             This function is used after generating the descriptions to add the new names of
1221             rivers and trails to the existing map.
1222              
1223             =cut
1224              
1225             sub add_labels {
1226 0     0 1 0 my $map = shift;
1227 0         0 $map =~ s/$line_re/get_label($1,$2)/ge;
  0         0  
1228 0         0 return $map;
1229             }
1230              
1231             =item get_label
1232              
1233             This function returns the name of a line.
1234              
1235             =cut
1236              
1237             sub get_label {
1238 0     0 1 0 my $map_line = shift;
1239 0         0 my $type = shift;
1240 0         0 my @hexes = split(/-/, $map_line);
1241             LINE:
1242 0         0 for my $line (@{$extra->{$hexes[0]}}) {
  0         0  
1243 0 0       0 next unless $line->{type} eq $type;
1244 0         0 for my $hex (@hexes) {
1245 0         0 my @line = @{$line->{line}};
  0         0  
1246 0 0       0 next LINE unless grep(/$hex/, @line);
1247             }
1248 0         0 my $label = $line->{name};
1249 0         0 return qq{$map_line $type "$label"};
1250             }
1251 0         0 return qq{$map_line $type};
1252             }
1253              
1254             =item xy
1255              
1256             This is a helper function to turn "0101" into ("01", "01") which is equivalent
1257             to (1, 1).
1258              
1259             =cut
1260              
1261             sub xy {
1262 0     0 1 0 my $coordinates = shift;
1263 0         0 return (substr($coordinates, 0, 2), substr($coordinates, 2));
1264             }
1265              
1266             =item coordinates
1267              
1268             This is a helper function to turn (1, 1) back into "0101".
1269              
1270             =cut
1271              
1272             sub coordinates {
1273 0     0 1 0 my ($x, $y) = @_;
1274 0         0 return sprintf("%02d%02d", $x, $y);
1275             }
1276              
1277             =item neighbour
1278              
1279             This is a helper function that takes the coordinates of a hex, a reference like
1280             [1,1] or regular coordinates like "0101", and a direction from 0 to 5, and
1281             returns the coordinates of the neighbouring hex in that direction.
1282              
1283             =cut
1284              
1285             my $delta = [[[-1, 0], [ 0, -1], [+1, 0], [+1, +1], [ 0, +1], [-1, +1]], # x is even
1286             [[-1, -1], [ 0, -1], [+1, -1], [+1, 0], [ 0, +1], [-1, 0]]]; # x is odd
1287              
1288             sub neighbour {
1289             # $hex is [x,y] or "0101" and $i is a number 0 .. 5
1290 0     0 1 0 my ($hex, $i) = @_;
1291 0 0       0 $hex = [xy($hex)] unless ref $hex;
1292             # return is a string like "0102"
1293 0         0 return coordinates(
1294             $hex->[0] + $delta->[$hex->[0] % 2]->[$i]->[0],
1295             $hex->[1] + $delta->[$hex->[0] % 2]->[$i]->[1]);
1296             }
1297              
1298             =item neighbours
1299              
1300             This is a helper function that takes map_data and the coordinates of a hex, a
1301             reference like [1,1] or regular coordinates like "0101", and returns a list of
1302             existing neighbours, or the string "[…]". This makes a difference at the edge of
1303             the map.
1304              
1305             =cut
1306              
1307             sub neighbours {
1308 0     0 1 0 my $map_data = shift;
1309 0         0 my $hex = shift;
1310 0         0 my @neighbours;
1311 0 0       0 $hex = [xy($hex)] unless ref $hex;
1312 0         0 for my $i (0 .. 5) {
1313 0         0 my $neighbour = neighbour($hex, $i);
1314             # $log->debug($neighbour);
1315 0 0       0 push(@neighbours, $neighbour) if $map_data->{$neighbour};
1316             }
1317 0 0       0 return "..." unless @neighbours;
1318 0         0 return @neighbours;
1319             }
1320              
1321             =item one
1322              
1323             This is a helper function that picks a random element from a list. This works
1324             both for actual lists and for references to lists.
1325              
1326             =cut
1327              
1328             sub one {
1329 0     0 1 0 my @arr = @_;
1330 0 0 0     0 @arr = @{$arr[0]} if @arr == 1 and ref $arr[0] eq 'ARRAY';
  0         0  
1331 0         0 return $arr[int(rand(scalar @arr))];
1332             }
1333              
1334             =item one_step_to
1335              
1336             Given a hex to start from, check all directions and figure out which neighbour
1337             is closer to your destination. Return the coordinates of this neighbour.
1338              
1339             =cut
1340              
1341             sub one_step_to {
1342 0     0 1 0 my $from = shift;
1343 0         0 my $to = shift;
1344 0         0 my ($min, $best);
1345 0         0 for my $i (0 .. 5) {
1346             # make a new guess
1347 0         0 my ($x, $y) = ($from->[0] + $delta->[$from->[0] % 2]->[$i]->[0],
1348             $from->[1] + $delta->[$from->[0] % 2]->[$i]->[1]);
1349 0         0 my $d = ($to->[0] - $x) * ($to->[0] - $x)
1350             + ($to->[1] - $y) * ($to->[1] - $y);
1351 0 0 0     0 if (!defined($min) || $d < $min) {
1352 0         0 $min = $d;
1353 0         0 $best = [$x, $y];
1354             }
1355             }
1356 0         0 return $best;
1357             }
1358              
1359             =item compute_missing_points
1360              
1361             Return a list of coordinates in string form. Thus, given a list like ("0302",
1362             "0101") it will return ("0302", "0201", "0101").
1363              
1364             =cut
1365              
1366             sub compute_missing_points {
1367 0     0 1 0 my @result = ($_[0]); # "0101" not [01,02]
1368 0         0 my @points = map { [xy($_)] } @_;
  0         0  
1369             # $log->debug("Line: " . join(", ", map { coordinates(@$_) } @points));
1370 0         0 my $from = shift(@points);
1371 0         0 while (@points) {
1372             # $log->debug("Going from " . coordinates(@$from) . " to " . coordinates(@{$points[0]}));
1373 0         0 $from = one_step_to($from, $points[0]);
1374 0 0 0     0 shift(@points) if $from->[0] == $points[0]->[0] and $from->[1] == $points[0]->[1];
1375 0         0 push(@result, coordinates(@$from));
1376             }
1377 0         0 return @result;
1378             }
1379              
1380             =item same_direction
1381              
1382             Given two linear structures and a point of contact, return 1 if the these
1383             objects go in the same direction on way or the other.
1384              
1385             =cut
1386              
1387             sub same_direction {
1388 0     0 1 0 my $coord = shift;
1389 0         0 my $line1 = shift;
1390 0         0 my $line2 = shift;
1391             # $log->debug("same_direction: $coord, @$line1 and @$line2");
1392             # this code assumes that a line starts with $type at index 0
1393 0         0 my $j;
1394 0         0 for my $i (1 .. $#$line1) {
1395 0 0       0 if ($line1->[$i] eq $coord) {
1396 0         0 $j = $i;
1397 0         0 last;
1398             }
1399             }
1400             # $log->debug("same_direction: $coord has index $j in @$line1");
1401 0         0 for my $i1 ($j - 1, $j + 1) {
1402 0 0 0     0 next if $i1 == 0 or $i1 > $#$line1;
1403 0         0 my $next = $line1->[$i1];
1404 0         0 for my $i2 (1 .. $#$line2) {
1405 0 0       0 if ($line2->[$i2] eq $coord) {
1406 0 0 0     0 if ($line2->[$i2-1] and $next eq $line2->[$i2-1]
      0        
      0        
1407             or $line2->[$i2+1] and $next eq $line2->[$i2+1]) {
1408             # $log->debug("same direction at $coord: @$line1 and @$line2");
1409 0         0 return 1;
1410             }
1411             }
1412             }
1413             }
1414 0         0 return 0;
1415             }
1416              
1417             =item spread_name
1418              
1419             This function is used to spread a name along terrain features.
1420              
1421             =cut
1422              
1423             sub spread_name {
1424 0     0 1 0 my $map_data = shift;
1425 0         0 my $coordinates = shift;
1426 0         0 my $word = shift; # "name for white big mountain"
1427 0         0 my $key = shift; # "white"
1428 0         0 my @keys = split(/\//, $key); # ("white")
1429 0         0 my $name = shift; # "Vesuv"
1430 0         0 my %seen = ($coordinates => 1);
1431 0         0 $globals->{$key}->{$coordinates} = $name;
1432             # $log->debug("$word: $coordinates = $name");
1433 0         0 my @queue = map { neighbour($coordinates, $_) } 0..5;
  0         0  
1434 0         0 while (@queue) {
1435             # $log->debug("Working on the first item of @queue");
1436 0         0 my $coord = shift(@queue);
1437 0 0 0     0 next if $seen{$coord} or not $map_data->{$coord};
1438 0         0 $seen{$coord} = 1;
1439 0 0       0 if (intersect(@keys, @{$map_data->{$coord}})) {
  0         0  
1440             $log->error("$word for $coord is already something else")
1441 0 0       0 if $names{"$word for $coord"};
1442 0         0 $names{"$word: $coord"} = $name; # "name for white big mountain: 0102"
1443             # $log->debug("$coord: $name for @keys");
1444 0         0 $globals->{$_}->{$coord} = $name for @keys;
1445             # $log->debug("$word: $coord = $name");
1446 0         0 push(@queue, map { neighbour($coord, $_) } 0..5);
  0         0  
1447             }
1448             }
1449             }
1450              
1451             =item markdown
1452              
1453             This allows us to generate Markdown output.
1454              
1455             =cut
1456              
1457             sub markdown {
1458 5     5 1 20 my $descriptions = shift;
1459 5   100     38 my $separator = shift || "\n\n---\n\n";
1460             my @paragraphs = map {
1461             # remove inline images
1462 5         21 s!]*>!!g;
  9         165  
1463             # empty spans left after img has been removed
1464 9         69 s!]*>\s*!!g;
1465             # remaining spans result in Japanese brackets around their text
1466 9         162 s!]*>\s*!「!g;
1467 9         97 s!\s*!」!g;
1468             # emphasis
1469 9         1192 s!!**!g;
1470 9         379 s!!*!g;
1471 9         49 s!!_!g;
1472             # remove links but leave their text
1473 9         1549 s!]*>!!g;
1474             # closing paragraph tags are optional
1475 9         48 s!

!!g;
1476             # paragraph breaks
1477 9         537 s!!\n\n!g;
1478             # blockquotes
1479 9         32 s!
(.*?)
!local $_ = $1; s/^/\n> /g; $_!gem;
  0         0  
  0         0  
  0         0  
1480             # unreplaced references (nearby, other, later)
1481 9         25 s!(.*?)!$1!g;
1482             # return what's left
1483 9         30 markdown_lists($_);
1484             } @$descriptions;
1485 5         211 return join($separator, @paragraphs);
1486             }
1487              
1488             sub markdown_lists {
1489 9     9 0 21 $_ = shift;
1490 9         22 my ($str, @list);
1491 9         370 for (split(/(<.*?>)/)) {
1492 128 50       503 if (/^$/) { unshift(@list, '1.'); $str .= "\n" }
  0 50       0  
  0 50       0  
    50          
    50          
1493 0         0 elsif (/^$/) { unshift(@list, '*'); $str .= "\n" }
  0         0  
1494             elsif (/^
  • $/) {
  • 1495             $str .= (@list > 1
    1496             # all the list markers except for the current one
    1497 0 0       0 ? (" " x (sum map { length($_) + 1 } @list[1..$#list]))
      0         0  
    1498             : "")
    1499             . $list[0] . " " }
    1500 0         0 elsif (/^<\/(ol|ul)>$/) { shift(@list) }
    1501 0 0       0 elsif (/^<\/li>$/) { $str .= "\n" unless $str =~ /\n$/ }
    1502 128         463 else { $str .= $_ }
    1503             }
    1504 9         197 return $str;
    1505             }
    1506              
    1507             1;