File Coverage

blib/lib/Game/HexDescribe/Utils.pm
Criterion Covered Total %
statement 297 669 44.3
branch 99 326 30.3
condition 52 161 32.3
subroutine 31 47 65.9
pod 37 38 97.3
total 516 1241 41.5


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 2     2   1221 use Text::Autoformat;
  2         41659  
  2         141  
37 2     2   848 use Game::HexDescribe::Log;
  2         5  
  2         60  
38 2     2   16 use Modern::Perl;
  2         7  
  2         18  
39 2     2   426 use Mojo::URL;
  2         4  
  2         18  
40 2     2   55 use Mojo::File;
  2         4  
  2         77  
41 2     2   11 use List::Util qw(shuffle);
  2         16  
  2         123  
42 2     2   884 use Array::Utils qw(intersect);
  2         691  
  2         136  
43 2     2   15 use Encode qw(decode_utf8);
  2         7  
  2         91  
44 2     2   12 use utf8;
  2         16  
  2         14  
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 3     3 1 65 my ($name, $dir) = @_;
74 3         34 $log->debug("Looking for table '$name' in the contrib directory '$dir'");
75 3         61 my $file = Mojo::File->new("$dir/hex-describe-$name-table.txt");
76 3 50       52 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 322 my ($name, $dir) = @_;
89 1         8 $log->debug("Looking for map in the contrib directory: $dir");
90 1         20 my $file = Mojo::File->new("$dir/hex-describe-$name-map.txt");
91 1 50       18 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 3     3 1 9 my $text = shift;
126 3         14324 $log->debug("parse_table: parsing " . length($text) . " characters");
127 3         44 my $data = {};
128 3         8 my $words = "[^\[\]\n]*";
129 3         9 my (%aliases, $key, $c, $t);
130 3         101529 for my $line (split(/\r?\n/, $text)) {
131 122160 100 100     749145 if ($line =~ /^;([^#\r\n]+)/) {
    100 66        
    50          
132 6237         11986 $key = $1;
133 6237 50       16245 $log->warn("parse_table: reset '$key'") if exists $data->{$key};
134 6237         22241 $data->{$key} = {}; # reset, don't merge
135             } elsif ($key and ($c, $t) = $line =~ /^(\d+),(.*)/) {
136 108561         223607 $t =~ s/\*\*(.*?)\*\*/$1<\/strong>/g;
137 108561         176244 $t =~ s/\*(.*?)\*/$1<\/em>/g;
138 108561         251730 my %h = (text => $t);
139 108561 100       213533 if ($c == 0) {
140 18         60 $h{unique} = 1;
141 18         59 $c = 1;
142             }
143 108561         198341 $h{count} = $c;
144 108561         192127 $data->{$key}->{total} += $c;
145 108561         139156 push(@{$data->{$key}->{lines}}, \%h);
  108561         251699  
146             # [foo as bar]
147 108561         340105 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 108561         396194 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 3         9222 for my $table (keys %$data) {
160 6237         9228 for my $line (@{$data->{$table}->{lines}}) {
  6237         21183  
161 108561         288407 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|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 3         2969 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             is 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 2     2 1 6 %names = ();
251 2         6 %locals = ();
252 2         5 $globals = undef;
253 2         5 $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     18 if ($map and $map->isa('Mojo::Upload')) {
269 0         0 $map = $map->slurp();
270             };
271 1         13 for my $hex (split(/\r?\n/, $map)) {
272 1 50       15 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     12 while($hex =~ /\G([a-z]="[^"]+")\s*/cg or $hex =~ /(\S+)/cg) {
277 1         9 push(@types, $1);
278             }
279 1         7 $map_data->{"$x$y"} = \@types;
280             }
281             }
282 1         3 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         11 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         4 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         3 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 3 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 3 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         26 process_map_merge_lines($map_data, $lines);
450 1         4 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         4 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 330     330 1 446 my $h = shift;
470 330         588 my $total = $h->{total};
471 330         485 my $lines = $h->{lines};
472 330         656 my $roll = int(rand($total)) + 1;
473 330         441 my $i = 0;
474 330         606 for my $line (@$lines) {
475 1707         2521 $i += $line->{count};
476 1707 100       2932 if ($i >= $roll) {
477 330 50       593 if ($line->{unique}) {
478 0         0 $h->{total} -= $line->{count};
479 0         0 $line->{count} = 0;
480             }
481 330         824 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 16 my $url = shift;
502 10         15 my $redirects = shift;
503 10 50 33     72 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 331     331 1 523 my $map_data = shift;
553 331         455 my $table_data = shift;
554 331         429 my $level = shift;
555 331         453 my $coordinates = shift;
556 331         411 my $words = shift;
557 331         517 my $word = shift;
558 331         422 my $redirects = shift;
559 331         406 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 331         571 for my $context (grep( { $_ ne $word } @$words), $word) {
  333         934  
564 342 100       663 my $key = ($context eq $word ? $word : "$context $word");
565             # $log->debug("$coordinates: looking for a $key table") if $coordinates eq "0109";
566 342 100       994 if ($table_data->{$key}) {
567 330         703 $text = pick_description($table_data->{$key});
568             # $log->debug("$coordinates → $key → $text");
569 330         647 my $seed = int(rand(~0)); # maxint
570 330         1289 $text =~ s/\[\[redirect (https:.*?)\]\]/my $url = $1; $url =~ s!\$seed!$seed!; resolve_redirect($url, $redirects)/ge;
  10         28  
  10         43  
  10         25  
571             # this makes sure we recursively resolve all references, in order, because
572             # we keep rescanning from the beginning
573 330         476 my $last = $text;
574 330         1730 while ($text =~ s/\[([^][]*)\]/describe($map_data,$table_data,$level+1,$coordinates,[$1], $redirects)/e) {
  480         1761  
575 480 50       1119 if ($last eq $text) {
576 0         0 $log->error("Infinite loop: $text");
577 0         0 last;
578             }
579 480         3540 $last = $text;
580             };
581 330         523 last;
582             }
583             }
584             # $log->debug("$word → $text ") if $text;
585 331         671 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 493     493 1 745 my $map_data = shift;
600 493         646 my $table_data = shift;
601 493         637 my $level = shift;
602 493         684 my $coordinates = shift;
603 493         629 my $words = shift;
604 493         670 my $redirects = shift;
605 493 50       968 $log->error("Recursion level $level exceeds 20 in $coordinates (@$words)!") if $level > 20;
606 493 50       780 return '' if $level > 20;
607 493 100       931 if ($level == 1) {
608 3         20 %locals = (hex => $coordinates); # reset once per paragraph
609 3         17 for my $word (@$words) {
610 4 50 33     27 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         12 $locals{$word} = 1;
617             }
618             }
619             }
620 493         661 my @descriptions;
621 493         822 for my $word (@$words) {
622             # valid dice rolls: 1d6, 1d6+1, 1d6x10, 1d6x10+1
623 494 100 0     10822 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          
    100          
    50          
    100          
    100          
    100          
    50          
    50          
    50          
    50          
    50          
    0          
    0          
624 41         75 my $r = 0;
625 41 50       74 if ($c) {
626 0         0 $r = $c;
627             } else {
628 41         99 for(my $i = 0; $i < $n; $i++) {
629 56         175 $r += int(rand($d)) + 1;
630             }
631 41   100     156 $r *= $m||1;
632 41   100     117 $r += $p||0;
633 41 50 33     91 $r = $min if defined $min and $r < $min;
634 41 50 33     96 $r = $max if defined $max and $r > $max;
635             }
636             # $log->debug("rolling dice: $word = $r");
637 41 100       83 $locals{$save_as} = $r if $save_as;
638 41 50       125 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 6         19 my $name = $names{$word};
665             # $log->debug("memoized: $word is $name") if $name;
666 6 50       18 return $name if $name;
667 6         36 $name = pick($map_data, $table_data, $level, $coordinates, $words, $word, $redirects);
668 6 50       19 next unless $name;
669 6         26 $names{$word} = $name;
670             # $log->debug("$word is $name");
671 6         16 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|other|later) ./) {
738             # skip on the first pass
739 1         8 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 93         235 my $key = $1;
748 93 50 33     396 return $locals{$key}->[0] if exists $locals{$key} and ref($locals{$key}) eq 'ARRAY';
749 93 50       489 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, attempt picking a new one");
752 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
753 0 0       0 if ($text) {
754 0         0 $locals{$key} = $text;
755 0         0 push(@descriptions, $text . "*");
756             } else {
757 0         0 $log->error("[$key] is undefined for $coordinates");
758 0         0 push(@descriptions, "…");
759             }
760             } elsif ($word =~ /^(?:(here|global) )?with (.+?)(?: as (.+))?$/) {
761 3         11 my ($where, $key, $alias) = ($1, $2, $3);
762 3         13 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
763 3 50       8 next unless $text;
764 3         11 $locals{$key} = [$text]; # start a new list
765 3 50       8 $locals{$alias} = $text if $alias;
766 3 50 33     9 $globals->{$key}->{$coordinates} = $text if $where and $where eq 'here';
767 3 0 33     8 $globals->{$alias}->{$coordinates} = $text if $where and $where eq 'here' and $alias;
      33        
768 3 50 33     11 $globals->{$key}->{global} = $text if $where and $where eq 'global';
769 3 0 33     7 $globals->{$alias}->{global} = $text if $where and $where eq 'global' and $alias;
      33        
770 3         9 push(@descriptions, $text);
771             } elsif ($word =~ /^(?:(here|global) )?and (.+?)(?: as (.+))?$/) {
772 2         8 my ($where, $key, $alias) = ($1, $2, $3);
773 2         3 my $found = 0;
774             # limited attempts to find a unique entry for an existing list (instead of
775             # modifying the data structures)
776 2         7 for (1 .. 20) {
777 2         6 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
778 2 50       11 $log->warn("[and $key] is used before [with $key] is done in $coordinates") if ref $locals{$key} ne 'ARRAY';
779 2 50       8 $locals{$key} = [$text] if ref $locals{$key} ne 'ARRAY';
780 2 50 33     8 next if not $text or grep { $text eq $_ } @{$locals{$key}};
  2         9  
  2         6  
781 2         4 push(@{$locals{$key}}, $text);
  2         5  
782 2         5 push(@descriptions, $text);
783 2 50       7 $locals{$alias} = $text if $alias;
784 2 50 33     8 $globals->{$key}->{$coordinates} = $text if $where and $where eq 'here';
785 2 0 33     7 $globals->{$alias}->{$coordinates} = $text if $where and $where eq 'here' and $alias;
      33        
786 2 50 33     7 $globals->{$key}->{global} = $text if $where and $where eq 'global';
787 2 0 33     4 $globals->{$alias}->{global} = $text if $where and $where eq 'global' and $alias;
      33        
788 2         5 $found = 1;
789 2         3 last;
790             }
791 2 50       9 if (not $found) {
792 0         0 $log->warn("[and $key] not unique in $coordinates");
793 0         0 push(@descriptions, "…");
794             }
795             } elsif ($word =~ /^capitalize (.+)/) {
796 0         0 my $key = $1;
797 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
798 0 0       0 next unless $text;
799 0         0 $locals{$key} = $text;
800 0         0 push(@descriptions, ucfirst $text);
801             } elsif ($word =~ /^titlecase (.+)/) {
802 0         0 my $key = $1;
803 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
804 0 0       0 next unless $text;
805 0         0 $locals{$key} = $text;
806 0         0 push(@descriptions, autoformat($text, { case => 'titlecase' }));
807             } elsif ($word =~ /^highlightcase (.+)/) {
808 0         0 my $key = $1;
809 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
810 0 0       0 next unless $text;
811 0         0 $locals{$key} = $text;
812 0         0 push(@descriptions, autoformat($text, { case => 'highlight' }));
813             } elsif ($word =~ /^normalize-elvish (.+)/) {
814 0         0 my $key = $1;
815 0         0 my $text = normalize_elvish($key);
816 0 0       0 next unless $text;
817 0         0 $locals{$key} = $text;
818 0         0 push(@descriptions, $text);
819             } elsif ($word =~ /^(?:(here|global) )?(?:(save|store|quote) )?(.+?)(?: as (.+))?$/) {
820 348         1215 my ($where, $action, $key, $alias) = ($1, $2, $3, $4);
821 348         494 my $text;
822 348 100 100     815 if (not $action or $action eq "save") {
823             # no action and save are with lookup
824 320         1439 $text = pick($map_data, $table_data, $level, $coordinates, $words, $key, $redirects);
825             } else {
826             # quote and store are without lookup
827 28         54 $text = $key;
828             }
829 348 100       711 next unless $text;
830 268         632 $locals{$key} = $text;
831 268 100       473 $locals{$alias} = $text if $alias;
832 268 100 100     524 $globals->{$key}->{$coordinates} = $text if $where and $where eq 'here';
833 268 100 100     512 $globals->{$alias}->{$coordinates} = $text if $where and $where eq 'here' and $alias;
      66        
834 268 100 100     479 $globals->{$key}->{global} = $text if $where and $where eq 'global';
835 268 100 100     511 $globals->{$alias}->{global} = $text if $where and $where eq 'global' and $alias;
      66        
836 268 100 66     810 push(@descriptions, $text) if not $action or $action eq "quote";
837             } elsif ($level > 1 and not exists $table_data->{$word} and not $locals{$word}) {
838             # on level one, many terrain types do not exist (e.g. river-start)
839 0         0 $log->error("unknown table for $coordinates/$level: $word");
840             } elsif ($level > 1 and not $table_data->{$word} and not $locals{$word}) {
841             # on level one, many terrain types do not exist (e.g. river-start)
842 0         0 $log->error("empty table for $coordinates/$level: $word");
843             } else {
844 0         0 my $text = pick($map_data, $table_data, $level, $coordinates, $words, $word, $redirects);
845             # remember it's legitimate to have no result for a table, and remember we
846             # cannot use a local with the same name that's defined because sometimes
847             # locals are simply defined as "1" since they start out as "words" and I
848             # don't want to make "1" a special case to ignore, here
849 0 0       0 next unless defined $text;
850 0         0 $locals{$word} = $text;
851 0         0 push(@descriptions, $text);
852             }
853             }
854 399         2318 return join(' ', @descriptions);
855             }
856              
857             =item describe_text
858              
859             This function does what C does, but for simple text without hex
860             coordinates.
861              
862             =cut
863              
864             sub describe_text {
865 1     1 1 4 my $input = shift;
866 1         4 my $table_data = shift;
867 1         2 my $redirects = shift;
868 1         2 my @descriptions;
869 1         5 init();
870 1         20 for my $text (split(/\r?\n/, $input)) {
871             # recusion level 2 makes sure we don't reset %locals
872 10         48 $text =~ s/\[(.*?)\]/describe({},$table_data,2,"no map",[$1],$redirects)/ge;
  10         40  
873 10         47 push(@descriptions, process($text, $redirects));
874 10         58 %locals = (); # reset once per paragraph
875             }
876 1         25 return \@descriptions;
877             }
878              
879             =item normalize_elvish
880              
881             We do some post-processing of words, inspired by these two web pages, but using
882             our own replacements.
883             http://sindarinlessons.weebly.com/37---how-to-make-names-1.html
884             http://sindarinlessons.weebly.com/38---how-to-make-names-2.html
885              
886             =cut
887              
888             sub normalize_elvish {
889 0     0 1 0 my $original = shift;
890 0         0 my $name = $original;
891              
892 0         0 $name =~ s/(.) \1/$1/g;
893 0         0 $name =~ s/d t/d/g;
894 0         0 $name =~ s/a ui/au/g;
895 0         0 $name =~ s/nd m/dhm/g;
896 0         0 $name =~ s/n?d w/dhw/g;
897 0         0 $name =~ s/r gw/rw/g;
898 0         0 $name =~ s/^nd/d/;
899 0         0 $name =~ s/^ng/g/;
900 0         0 $name =~ s/th n?d/d/g;
901 0         0 $name =~ s/dh dr/dhr/g;
902 0         0 $name =~ s/ //g;
903              
904 0         0 $name =~ tr/âêîôûŷ/aeioúi/;
905 0         0 $name =~ s/ll$/l/;
906 0         0 $name =~ s/ben$/wen/g;
907 0         0 $name =~ s/bwi$/wi/;
908 0         0 $name =~ s/[^aeiouúi]ndil$/dil/g;
909 0         0 $name =~ s/ae/aë/g;
910 0         0 $name =~ s/ea/ëa/g;
911              
912 0         0 $name = ucfirst($name);
913              
914             # $log->debug("Elvish normalize: $original → $name");
915 0         0 return $name;
916             }
917              
918             =item process
919              
920             We do some post-processing after the description has been assembled: we move all
921             the IMG tags in a SPAN element with class "images". This makes it easier to lay
922             out the result using CSS.
923              
924             =cut
925              
926             sub process {
927 13     13 1 29 my $text = shift;
928 13         24 my $images = shift;
929 13 100       25 if ($images) {
930 10         129 $text =~ s/(]+?>)/$1<\/span>/g;
931             } else {
932 3         640 $text =~ s/(]+?>)//g;
933             }
934             # fix whilespace at the end of spans
935 13         810 $text =~ s/\s+<\/span>/<\/span> /g;
936             # strip empty paragraphs
937 13         653 $text =~ s/

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

938 13         648 $text =~ s/

\s*

/

/g;

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

!!g;
1429             # paragraph breaks
1430 3         1022 s!!\n\n!g;
1431             # blockquotes
1432 3         522 s!
(.*?)
!local $_ = $1; s/^/\n> /g; $_!gem;
  0         0  
  0         0  
  0         0  
1433             # unreplaced references (nearby, other, later)
1434 3         516 s!(.*?)!$1!g;
1435             # return what's left
1436 3         10 markdown_lists($_);
1437             } @$descriptions;
1438 1         142 return join($separator, @paragraphs);
1439             }
1440              
1441             sub markdown_lists {
1442 3     3 0 7 $_ = shift;
1443 3         4 my ($str, @list);
1444 3         619 for (split(/(<.*?>)/)) {
1445 122 50       346 if (/^$/) { unshift(@list, '1.'); $str .= "\n" }
  0 50       0  
  0 50       0  
    50          
    50          
1446 0         0 elsif (/^$/) { unshift(@list, '*'); $str .= "\n" }
  0         0  
1447 0         0 elsif (/^
  • $/) { $str .= " " x (4 * @list) . $list[0] . " " }
  • 1448 0         0 elsif (/^<\/(ol|ul)>$/) { shift(@list) }
    1449 0 0       0 elsif (/^<\/li>$/) { $str .= "\n" unless $str =~ /\n$/ }
    1450 122         293 else { $str .= $_ }
    1451             }
    1452 3         20 return $str;
    1453             }
    1454              
    1455             1;