File Coverage

blib/lib/Game/HexDescribe/Utils.pm
Criterion Covered Total %
statement 170 668 25.4
branch 70 326 21.4
condition 28 161 17.3
subroutine 19 47 40.4
pod 37 38 97.3
total 324 1240 26.1


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

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

937 10         39 $text =~ s/

\s*

/

/g;

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

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