File Coverage

blib/lib/Game/TextMapper/Traveller.pm
Criterion Covered Total %
statement 239 239 100.0
branch 134 144 93.0
condition 111 120 92.5
subroutine 26 26 100.0
pod 1 15 6.6
total 511 544 93.9


line stmt bran cond sub pod time code
1             # Copyright (C) 2009-2021 Alex Schroeder
2             #
3             # This program is free software: you can redistribute it and/or modify it under
4             # the terms of the GNU Affero General Public License as published by the Free
5             # Software Foundation, either version 3 of the License, or (at your option) any
6             # later version.
7             #
8             # This program is distributed in the hope that it will be useful, but WITHOUT
9             # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10             # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
11             # details.
12             #
13             # You should have received a copy of the GNU Affero General Public License along
14             # with this program. If not, see .
15              
16             =encoding utf8
17              
18             =head1 NAME
19              
20             Game::TextMapper::Traveller - generate Traveller subsector maps
21              
22             =head1 DESCRIPTION
23              
24             This generates subsector maps suitable for the Traveller game in its various
25             editions. Trade and communication routes are based on starports, bases, and
26             trade codes and jump distance; the potential connections are then winnowed down
27             using a minimal spanning tree.
28              
29             =head1 METHODS
30              
31             =cut
32              
33             package Game::TextMapper::Traveller;
34 11     11   82 use Game::TextMapper::Log;
  11         28  
  11         464  
35 11     11   60 use Modern::Perl '2018';
  11         44  
  11         99  
36 11     11   3621 use List::Util qw(shuffle max any);
  11         24  
  11         877  
37 11     11   74 use Mojo::Base -base;
  11         24  
  11         119  
38 11     11   2117 use Role::Tiny::With;
  11         52  
  11         700  
39 11     11   69 use Game::TextMapper::Constants qw($dx $dy);
  11         26  
  11         62707  
40             with 'Game::TextMapper::Schroeder::Hex';
41              
42             my $log = Game::TextMapper::Log->get;
43              
44             has 'rows' => 10;
45             has 'cols' => 8;
46             has 'digraphs';
47              
48             =head2 generate_map
49              
50             This method takes no arguments. Subsectors are always 8×10.
51              
52             =cut
53              
54             sub generate_map {
55 2     2 1 17 my $self = shift;
56 2         11 $self->digraphs($self->compute_digraphs);
57             # coordinates are an index into the system array
58 2         35 my @coordinates = (0 .. $self->rows * $self->cols - 1);
59 2         76 my @randomized = shuffle(@coordinates);
60             # %systems maps coordinates to arrays of tiles
61 2         8 my %systems = map { $_ => $self->system() } grep { roll1d6() > 3 } @randomized; # density
  81         251  
  160         288  
62 2         25 my $comms = $self->comms(\%systems);
63 2 100       10 my $tiles = [map { $systems{$_} || ["empty"] } (@coordinates)];
  160         437  
64 2         19 return $self->to_text($tiles, $comms);
65             }
66              
67             # Each system is an array of tiles, e.g. ["size-1", "population-3", ...]
68             sub system {
69 81     81 0 147 my $self = shift;
70 81         165 my $size = roll2d6() - 2;
71 81         242 my $atmosphere = max(0, roll2d6() - 7 + $size);
72 81 100       310 $atmosphere = 0 if $size == 0;
73 81         188 my $hydro = roll2d6() - 7 + $atmosphere;
74 81 100 100     334 $hydro -= 4 if $atmosphere < 2 or $atmosphere >= 10;
75 81 100 100     308 $hydro = 0 if $hydro < 0 or $size < 2;
76 81 100       201 $hydro = 10 if $hydro > 10;
77 81         182 my $population = roll2d6() - 2;
78 81         182 my $government = max(0, roll2d6() - 7 + $population);
79 81         180 my $law = max(0, roll2d6() - 7 + $government);
80 81         239 my $starport = roll2d6();
81 81         138 my $naval_base = 0;
82 81         151 my $scout_base = 0;
83 81         137 my $research_base = 0;
84 81         135 my $pirate_base = 0;
85 81         144 my $tech = roll1d6();
86 81 100       298 if ($starport <= 4) {
    100          
    100          
    100          
    100          
87 11         24 $starport = "A";
88 11         19 $tech += 6;
89 11 50       25 $scout_base = 1 if roll2d6() >= 10;
90 11 100       22 $naval_base = 1 if roll2d6() >= 8;
91 11 100       23 $research_base = 1 if roll2d6() >= 8;
92             } elsif ($starport <= 6) {
93 26         52 $starport = "B";
94 26         44 $tech += 4;
95 26 100       59 $scout_base = 1 if roll2d6() >= 9;
96 26 100       56 $naval_base = 1 if roll2d6() >= 8;
97 26 100       55 $research_base = 1 if roll2d6() >= 10;
98             } elsif ($starport <= 8) {
99 23         44 $starport = "C";
100 23         36 $tech += 2;
101 23 100       48 $scout_base = 1 if roll2d6() >= 8;
102 23 100       47 $research_base = 1 if roll2d6() >= 10;
103 23 100       47 $pirate_base = 1 if roll2d6() >= 12;
104             } elsif ($starport <= 9) {
105 9         19 $starport = "D";
106 9 100       22 $scout_base = 1 if roll2d6() >= 7;
107 9 100       19 $pirate_base = 1 if roll2d6() >= 10;
108             } elsif ($starport <= 11) {
109 11         23 $starport = "E";
110 11 100       24 $pirate_base = 1 if roll2d6() >= 10;
111             } else {
112 1         4 $starport = "X";
113 1         2 $tech -= 4;
114             }
115 81 100       221 $tech += 1 if $size <= 4;
116 81 100       214 $tech += 1 if $size <= 1; # +2 total
117 81 100 100     313 $tech += 1 if $atmosphere <= 3 or $atmosphere >= 10;
118 81 100       186 $tech += 1 if $hydro >= 9;
119 81 100       174 $tech += 1 if $hydro >= 10; # +2 total
120 81 100 100     372 $tech += 1 if $population >= 1 and $population <= 5;
121 81 100       199 $tech += 2 if $population >= 9;
122 81 100       177 $tech += 2 if $population >= 10; # +4 total
123 81 100 100     315 $tech += 1 if $government == 0 or $government == 5;
124 81 50       183 $tech -= 2 if $government == 13; # D
125 81 50       181 $tech = 0 if $tech < 0;
126 81         186 my $gas_giant = roll2d6() <= 9;
127 81         239 my $name = $self->compute_name();
128 81 100       232 $name = uc($name) if $population >= 9;
129 81         199 my $uwp = join("", $starport, map { code($_) } $size, $atmosphere, $hydro, $population, $government, $law) . "-" . code($tech);
  486         901  
130             # these things determine the order in which text is generated by Hex Describe
131 81         198 my @tiles;
132 81 100       249 push(@tiles, "gas") if $gas_giant;
133 81         209 push(@tiles, "size-" . code($size));
134 81 100       204 push(@tiles, "asteroid")
135             if $size == 0;
136 81         159 push(@tiles, "atmosphere-" . code($atmosphere));
137 81 100       207 push(@tiles, "vacuum")
138             if $atmosphere == 0;
139 81         155 push(@tiles, "hydrosphere-" . code($hydro));
140 81 50       224 push(@tiles, "water")
141             if $hydro eq "A";
142 81 100 100     316 push(@tiles, "desert")
143             if $atmosphere >= 2
144             and $hydro == 0;
145 81 50 66     250 push(@tiles, "ice")
146             if $hydro >= 1
147             and $atmosphere <= 1;
148 81 100 100     274 push(@tiles, "fluid")
149             if $hydro >= 1
150             and $atmosphere >= 10;
151 81         164 push(@tiles, "population-" . code($population));
152 81 100 100     262 push(@tiles, "barren")
      66        
153             if $population eq 0
154             and $law eq 0
155             and $government eq 0;
156 81 100 100     343 push(@tiles, "low")
157             if $population >= 1 and $population <= 3;
158 81 100       204 push(@tiles, "high")
159             if $population >= 9;
160 81 100 100     507 push(@tiles, "agriculture")
      100        
      100        
      100        
      100        
161             if $atmosphere >= 4 and $atmosphere <= 9
162             and $hydro >= 4 and $hydro <= 8
163             and $population >= 5 and $population <= 7;
164 81 100 100     322 push(@tiles, "non-agriculture")
      100        
165             if $atmosphere <= 3
166             and $hydro <= 3
167             and $population >= 6;
168             push(@tiles, "industrial")
169 81 100 100 359   543 if any { $atmosphere == $_ } 0, 1, 2, 4, 7, 9
  359         730  
170             and $population >= 9;
171 81 100       413 push(@tiles, "non-industrial")
172             if $population <= 6;
173 81 100 100     530 push(@tiles, "rich")
      100        
      100        
      100        
      66        
174             if $government >= 4 and $government <= 9
175             and ($atmosphere == 6 or $atmosphere == 8)
176             and $population >= 6 and $population <= 8;
177 81 100 100     323 push(@tiles, "poor")
      100        
178             if $atmosphere >= 2 and $atmosphere <= 5
179             and $hydro <= 3;
180 81         165 push(@tiles, "tech-" . code($tech));
181 81         191 push(@tiles, "government-" . code($government));
182 81         192 push(@tiles, "starport-$starport");
183 81         146 push(@tiles, "law-" . code($law));
184 81 100       194 push(@tiles, "naval") if $naval_base;
185 81 100       178 push(@tiles, "scout") if $scout_base;
186 81 100       182 push(@tiles, "research") if $research_base;
187 81 100       178 push(@tiles, "pirate", "red") if $pirate_base;
188 81 100 100     862 push(@tiles, "amber")
      100        
189             if not $pirate_base
190             and ($atmosphere >= 10
191             or $population and $government == 0
192             or $population and $law == 0
193             or $government == 7
194             or $government == 10
195             or $law >= 9);
196             # last is the name
197 81         264 push(@tiles, qq{name="$name"}, qq{uwp="$uwp"});
198 81         411 return \@tiles;
199             }
200              
201             sub code {
202 1134     1134 0 1886 my $code = shift;
203 1134 100       3510 return $code if $code <= 9;
204 120         459 return chr(55+$code); # 10 is A
205             }
206              
207             sub compute_digraphs {
208 2     2 0 36 my @first = qw(b c d f g h j k l m n p q r s t v w x y z
209             b c d f g h j k l m n p q r s t v w x y z .
210             sc ng ch gh ph rh sh th wh zh wr qu
211             st sp tr tw fl dr pr dr);
212             # make missing vowel rare
213 2         11 my @second = qw(a e i o u a e i o u a e i o u .);
214 2         6 my @d;
215 2         16 for (1 .. 10+rand(20)) {
216 32         72 push(@d, one(@first));
217 32         65 push(@d, one(@second));
218             }
219 2         27 return \@d;
220             }
221              
222             sub compute_name {
223 81     81 0 143 my $self = shift;
224 81         135 my $max = scalar @{$self->digraphs};
  81         349  
225 81         531 my $length = 3 + rand(3); # length of name before adding one more
226 81         145 my $name = '';
227 81         251 while (length($name) < $length) {
228 197         778 my $i = 2*int(rand($max/2));
229 197         429 $name .= $self->digraphs->[$i];
230 197         879 $name .= $self->digraphs->[$i+1];
231             }
232 81         489 $name =~ s/\.//g;
233 81         332 return ucfirst($name);
234             }
235              
236             sub one {
237 64     64 0 202 return $_[int(rand(scalar @_))];
238             }
239              
240             sub roll1d6 {
241 1955     1955 0 4543 return 1+int(rand(6));
242             }
243              
244             sub roll2d6 {
245 857     857 0 1482 return roll1d6() + roll1d6();
246             }
247              
248             sub xy {
249 81     81 0 129 my $self = shift;
250 81         121 my $i = shift;
251 81         176 my $y = int($i / $self->cols);
252 81         466 my $x = $i % $self->cols;
253 81         464 $log->debug("$i ($x, $y)");
254 81         692 return $x + 1, $y + 1;
255             }
256              
257             sub label {
258 93     93 0 155 my ($self, $from, $to, $d, $label) = @_;
259 93         328 return sprintf("%02d%02d-%02d%02d $label", @$from[0..1], @$to[0..1]);
260             }
261              
262             # Communication routes have distance 1–2 and connect navy bases and A-class
263             # starports.
264             sub comms {
265 2     2 0 6 my $self = shift;
266 2         6 my %systems = %{shift()};
  2         39  
267 2         37 my @coordinates = map { [ $self->xy($_), $systems{$_} ] } keys(%systems);
  81         187  
268 2         20 my @comms;
269             my @trade;
270 2         0 my @rich_trade;
271 2         11 while (@coordinates) {
272 81         137 my $from = shift(@coordinates);
273 81         141 my ($x1, $y1, $system1) = @$from;
274 81 100   1115   267 next if any { /^starport-X$/ } @$system1; # skip systems without starports
  1115         1390  
275 80         180 for my $to (@coordinates) {
276 1576         2753 my ($x2, $y2, $system2) = @$to;
277 1576 100   21863   4542 next if any { /^starport-X$/ } @$system2; # skip systems without starports
  21863         27709  
278 1564         4613 my $d = $self->distance($x1, $y1, $x2, $y2);
279 1564 100 100     3634 if ($d <= 2 and match(qr/^(starport-[AB]|naval)$/, qr/^(starport-[AB]|naval)$/, $system1, $system2)) {
280 58         143 push(@comms, [$from, $to, $d]);
281             }
282 1564 50 66     3577 if ($d <= 2
      66        
283             # many of these can be eliminated, but who knows, perhaps one day
284             # directionality will make a difference
285             and (match(qr/^agriculture$/,
286             qr/^(agriculture|astroid|desert|high|industrial|low|non-agriculture|rich)$/,
287             $system1, $system2)
288             or match(qr/^asteroid$/,
289             qr/^(asteroid|industrial|non-agriculture|rich|vacuum)$/,
290             $system1, $system2)
291             or match(qr/^desert$/,
292             qr/^(desert|non-agriculture)$/,
293             $system1, $system2)
294             or match(qr/^fluid$/,
295             qr/^(fluid|industrial)$/,
296             $system1, $system2)
297             or match(qr/^high$/,
298             qr/^(high|low|rich)$/,
299             $system1, $system2)
300             or match(qr/^ice$/,
301             qr/^industrial$/,
302             $system1, $system2)
303             or match(qr/^industrial$/,
304             qr/^(agriculture|astroid|desert|fluid|high|industrial|non-industrial|poor|rich|vacuum|water)$/,
305             $system1, $system2)
306             or match(qr/^low$/,
307             qr/^(industrial|rich)$/,
308             $system1, $system2)
309             or match(qr/^non-agriculture$/,
310             qr/^(asteroid|desert|vacuum)$/,
311             $system1, $system2)
312             or match(qr/^non-industrial$/,
313             qr/^industrial$/,
314             $system1, $system2)
315             or match(qr/^rich$/,
316             qr/^(agriculture|desert|high|industrial|non-agriculture|rich)$/,
317             $system1, $system2)
318             or match(qr/^vacuum$/,
319             qr/^(asteroid|industrial|vacuum)$/,
320             $system1, $system2)
321             or match(qr/^water$/,
322             qr/^(industrial|rich|water)$/,
323             $system1, $system2))) {
324 56         182 push(@trade, [$from, $to, $d]);
325             }
326 1564 100 100     5781 if ($d <= 3
327             # subsidized liners only
328             and match(qr/^rich$/,
329             qr/^(asteroid|agriculture|desert|high|industrial|non-agriculture|water|rich|low)$/,
330             $system1, $system2)) {
331 18         78 push(@rich_trade, [$from, $to, $d]);
332             }
333             }
334             }
335 2         5 @comms = sort map { $self->label(@$_, "communication") } @{$self->minimal_spanning_tree(@comms)};
  34         61  
  2         14  
336 2         12 @trade = sort map { $self->label(@$_, "trade") } @{$self->minimal_spanning_tree(@trade)};
  42         85  
  2         11  
337 2         21 @rich_trade = sort map { $self->label(@$_, "rich") } @{$self->minimal_spanning_tree(@rich_trade)};
  17         34  
  2         10  
338 2         53 return [@rich_trade, @comms, @trade];
339             }
340              
341             sub match {
342 4004     4004 0 7132 my ($re1, $re2, $sys1, $sys2) = @_;
343 4004 100 100 50770   10475 return 1 if any { /$re1/ } @$sys1 and any { /$re2/ } @$sys2;
  50770         98959  
  6552         14077  
344 3901 100 100 45415   12376 return 1 if any { /$re2/ } @$sys1 and any { /$re1/ } @$sys2;
  45415         98711  
  12887         25561  
345 3872         19640 return 0;
346             }
347              
348             sub minimal_spanning_tree {
349             # http://en.wikipedia.org/wiki/Kruskal%27s_algorithm
350 6     6 0 11 my $self = shift;
351             # Initialize a priority queue Q to contain all edges in G, using the
352             # weights as keys.
353 6         2766 my @Q = sort { @{$a}[2] <=> @{$b}[2] } @_;
  417         414  
  417         474  
  417         528  
354             # Define a forest T ← Ø; T will ultimately contain the edges of the MST
355 6         21 my @T;
356             # Define an elementary cluster C(v) ← {v}.
357             my %C;
358 6         0 my $id;
359 6         13 foreach my $edge (@Q) {
360             # edge u,v is the minimum weighted route from u to v
361 132         150 my ($u, $v) = @{$edge};
  132         232  
362             # prevent cycles in T; add u,v only if T does not already contain
363             # a path between u and v; also silence warnings
364 132 100 100     443 if (not $C{$u} or not $C{$v} or $C{$u} != $C{$v}) {
      100        
365             # Add edge (v,u) to T.
366 93         172 push(@T, $edge);
367             # Merge C(v) and C(u) into one cluster, that is, union C(v) and C(u).
368 93 100 100     442 if ($C{$u} and $C{$v}) {
    100 66        
    100 66        
    50 33        
369 16         23 my @group;
370 16         61 foreach (keys %C) {
371 308 100       656 push(@group, $_) if $C{$_} == $C{$v};
372             }
373 16         86 $C{$_} = $C{$u} foreach @group;
374             } elsif ($C{$v} and not $C{$u}) {
375 18         56 $C{$u} = $C{$v};
376             } elsif ($C{$u} and not $C{$v}) {
377 31         88 $C{$v} = $C{$u};
378             } elsif (not $C{$u} and not $C{$v}) {
379 28         101 $C{$v} = $C{$u} = ++$id;
380             }
381             }
382             }
383 6         39 return \@T;
384             }
385              
386             sub to_text {
387 2     2 0 5 my $self = shift;
388 2         4 my $tiles = shift;
389 2         5 my $comms = shift;
390 2         6 my $text = "";
391 2         17 for my $x (0 .. $self->cols - 1) {
392 16         56 for my $y (0 .. $self->rows - 1) {
393 160         344 my $tile = $tiles->[$x + $y * $self->cols];
394 160 50       563 if ($tile) {
395 160         588 $text .= sprintf("%02d%02d @$tile\n", $x + 1, $y + 1);
396             }
397             }
398             }
399 2         20 $text .= join("\n", @$comms, "\ninclude traveller.txt\n");
400 2         10 $text .= $self->legend();
401 2         551 return $text;
402             }
403              
404             sub legend {
405 2     2 0 5 my $self = shift;
406 2         4 my $template = qq{# frame and legend};
407 2         7 my $x = int(($self->cols + 1) * 1.5 * $dx);
408 2         21 my $y = int(($self->rows + 1) * $dy + 5);
409 2         33 $template .= qq{
410             other },
411             $x = int(($self->cols + 1) * 0.75 * $dx);
412 2         16 $y = int(($self->rows + 1) * $dy - 60);
413 2         15 $template .= qq{
414             other coreward
415             other rimward};
416 2         7 $x = int($self->rows * $dy / 2);
417 2         12 $template .= qq{
418             other spinward
419             };
420 2         9 $y = int(($self->cols + 1) * 1.5 * $dx);
421 2         11 $template .= qq{
422             other trailing
423             };
424 2         7 $x = int(($self->rows + 0.5) * $dy);
425 2 50       12 $template .= qq{
426             other ◉ gas giant – ▲ scout base – ★ navy base – π research base – ☠ pirate base
427             } if $self->rows > 8;
428 2 50       19 $template .= qq{
429             other ■ imperial consulate – ☼ TAS – communication – trade long distance trade
430             } if $self->rows > 8;
431 2         58 return $template;
432             }
433              
434             1;