File Coverage

blib/lib/Game/TextMapper/Traveller.pm
Criterion Covered Total %
statement 239 239 100.0
branch 132 144 91.6
condition 112 120 93.3
subroutine 26 26 100.0
pod 1 15 6.6
total 510 544 93.7


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   89 use Game::TextMapper::Log;
  11         25  
  11         613  
35 11     11   83 use Modern::Perl '2018';
  11         26  
  11         143  
36 11     11   3952 use List::Util qw(shuffle max any);
  11         28  
  11         1133  
37 11     11   89 use Mojo::Base -base;
  11         24  
  11         118  
38 11     11   2362 use Role::Tiny::With;
  11         26  
  11         869  
39 11     11   87 use Game::TextMapper::Constants qw($dx $dy);
  11         32  
  11         68025  
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 18 my $self = shift;
56 2         14 $self->digraphs($self->compute_digraphs);
57             # coordinates are an index into the system array
58 2         39 my @coordinates = (0 .. $self->rows * $self->cols - 1);
59 2         98 my @randomized = shuffle(@coordinates);
60             # %systems maps coordinates to arrays of tiles
61 2         10 my %systems = map { $_ => $self->system() } grep { roll1d6() > 3 } @randomized; # density
  77         201  
  160         277  
62 2         24 my $comms = $self->comms(\%systems);
63 2 100       8 my $tiles = [map { $systems{$_} || ["empty"] } (@coordinates)];
  160         458  
64 2         15 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 77     77 0 147 my $self = shift;
70 77         155 my $size = roll2d6() - 2;
71 77         161 my $atmosphere = max(0, roll2d6() - 7 + $size);
72 77 100       188 $atmosphere = 0 if $size == 0;
73 77         152 my $hydro = roll2d6() - 7 + $atmosphere;
74 77 100 100     296 $hydro -= 4 if $atmosphere < 2 or $atmosphere >= 10;
75 77 100 100     270 $hydro = 0 if $hydro < 0 or $size < 2;
76 77 100       176 $hydro = 10 if $hydro > 10;
77 77         139 my $population = roll2d6() - 2;
78 77         157 my $government = max(0, roll2d6() - 7 + $population);
79 77         159 my $law = max(0, roll2d6() - 7 + $government);
80 77         170 my $starport = roll2d6();
81 77         130 my $naval_base = 0;
82 77         243 my $scout_base = 0;
83 77         155 my $research_base = 0;
84 77         124 my $pirate_base = 0;
85 77         163 my $tech = roll1d6();
86 77 100       297 if ($starport <= 4) {
    100          
    100          
    100          
    100          
87 13         28 $starport = "A";
88 13         27 $tech += 6;
89 13 100       30 $scout_base = 1 if roll2d6() >= 10;
90 13 100       461 $naval_base = 1 if roll2d6() >= 8;
91 13 100       29 $research_base = 1 if roll2d6() >= 8;
92             } elsif ($starport <= 6) {
93 19         33 $starport = "B";
94 19         37 $tech += 4;
95 19 100       41 $scout_base = 1 if roll2d6() >= 9;
96 19 100       44 $naval_base = 1 if roll2d6() >= 8;
97 19 100       42 $research_base = 1 if roll2d6() >= 10;
98             } elsif ($starport <= 8) {
99 25         45 $starport = "C";
100 25         43 $tech += 2;
101 25 100       50 $scout_base = 1 if roll2d6() >= 8;
102 25 100       64 $research_base = 1 if roll2d6() >= 10;
103 25 50       66 $pirate_base = 1 if roll2d6() >= 12;
104             } elsif ($starport <= 9) {
105 8         25 $starport = "D";
106 8 100       23 $scout_base = 1 if roll2d6() >= 7;
107 8 50       23 $pirate_base = 1 if roll2d6() >= 10;
108             } elsif ($starport <= 11) {
109 9         20 $starport = "E";
110 9 50       19 $pirate_base = 1 if roll2d6() >= 10;
111             } else {
112 3         8 $starport = "X";
113 3         7 $tech -= 4;
114             }
115 77 100       190 $tech += 1 if $size <= 4;
116 77 100       194 $tech += 1 if $size <= 1; # +2 total
117 77 100 100     296 $tech += 1 if $atmosphere <= 3 or $atmosphere >= 10;
118 77 100       183 $tech += 1 if $hydro >= 9;
119 77 100       163 $tech += 1 if $hydro >= 10; # +2 total
120 77 100 100     297 $tech += 1 if $population >= 1 and $population <= 5;
121 77 100       187 $tech += 2 if $population >= 9;
122 77 100       182 $tech += 2 if $population >= 10; # +4 total
123 77 100 100     265 $tech += 1 if $government == 0 or $government == 5;
124 77 50       190 $tech -= 2 if $government == 13; # D
125 77 50       166 $tech = 0 if $tech < 0;
126 77         136 my $gas_giant = roll2d6() <= 9;
127 77         202 my $name = $self->compute_name();
128 77 100       193 $name = uc($name) if $population >= 9;
129 77         169 my $uwp = join("", $starport, map { code($_) } $size, $atmosphere, $hydro, $population, $government, $law) . "-" . code($tech);
  462         834  
130             # these things determine the order in which text is generated by Hex Describe
131 77         180 my @tiles;
132 77 100       237 push(@tiles, "gas") if $gas_giant;
133 77         153 push(@tiles, "size-" . code($size));
134 77 100       186 push(@tiles, "asteroid")
135             if $size == 0;
136 77         156 push(@tiles, "atmosphere-" . code($atmosphere));
137 77 100       202 push(@tiles, "vacuum")
138             if $atmosphere == 0;
139 77         152 push(@tiles, "hydrosphere-" . code($hydro));
140 77 50       222 push(@tiles, "water")
141             if $hydro eq "A";
142 77 100 100     272 push(@tiles, "desert")
143             if $atmosphere >= 2
144             and $hydro == 0;
145 77 50 66     244 push(@tiles, "ice")
146             if $hydro >= 1
147             and $atmosphere <= 1;
148 77 100 100     244 push(@tiles, "fluid")
149             if $hydro >= 1
150             and $atmosphere >= 10;
151 77         146 push(@tiles, "population-" . code($population));
152 77 100 100     253 push(@tiles, "barren")
      100        
153             if $population eq 0
154             and $law eq 0
155             and $government eq 0;
156 77 100 100     275 push(@tiles, "low")
157             if $population >= 1 and $population <= 3;
158 77 100       202 push(@tiles, "high")
159             if $population >= 9;
160 77 100 100     445 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 77 100 100     318 push(@tiles, "non-agriculture")
      100        
165             if $atmosphere <= 3
166             and $hydro <= 3
167             and $population >= 6;
168             push(@tiles, "industrial")
169 77 100 100 335   403 if any { $atmosphere == $_ } 0, 1, 2, 4, 7, 9
  335         674  
170             and $population >= 9;
171 77 100       316 push(@tiles, "non-industrial")
172             if $population <= 6;
173 77 100 100     430 push(@tiles, "rich")
      100        
      100        
      100        
      100        
174             if $government >= 4 and $government <= 9
175             and ($atmosphere == 6 or $atmosphere == 8)
176             and $population >= 6 and $population <= 8;
177 77 100 100     318 push(@tiles, "poor")
      100        
178             if $atmosphere >= 2 and $atmosphere <= 5
179             and $hydro <= 3;
180 77         153 push(@tiles, "tech-" . code($tech));
181 77         265 push(@tiles, "government-" . code($government));
182 77         200 push(@tiles, "starport-$starport");
183 77         147 push(@tiles, "law-" . code($law));
184 77 100       190 push(@tiles, "naval") if $naval_base;
185 77 100       206 push(@tiles, "scout") if $scout_base;
186 77 100       166 push(@tiles, "research") if $research_base;
187 77 50       181 push(@tiles, "pirate", "red") if $pirate_base;
188 77 100 100     830 push(@tiles, "amber")
      66        
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 77         242 push(@tiles, qq{name="$name"}, qq{uwp="$uwp"});
198 77         352 return \@tiles;
199             }
200              
201             sub code {
202 1078     1078 0 1751 my $code = shift;
203 1078 100       3190 return $code if $code <= 9;
204 134         412 return chr(55+$code); # 10 is A
205             }
206              
207             sub compute_digraphs {
208 2     2 0 38 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         12 my @second = qw(a e i o u a e i o u a e i o u .);
214 2         5 my @d;
215 2         14 for (1 .. 10+rand(20)) {
216 42         101 push(@d, one(@first));
217 42         86 push(@d, one(@second));
218             }
219 2         25 return \@d;
220             }
221              
222             sub compute_name {
223 77     77 0 137 my $self = shift;
224 77         118 my $max = scalar @{$self->digraphs};
  77         224  
225 77         429 my $length = 3 + rand(3); # length of name before adding one more
226 77         133 my $name = '';
227 77         244 while (length($name) < $length) {
228 181         705 my $i = 2*int(rand($max/2));
229 181         389 $name .= $self->digraphs->[$i];
230 181         850 $name .= $self->digraphs->[$i+1];
231             }
232 77         426 $name =~ s/\.//g;
233 77         246 return ucfirst($name);
234             }
235              
236             sub one {
237 84     84 0 204 return $_[int(rand(scalar @_))];
238             }
239              
240             sub roll1d6 {
241 1861     1861 0 4212 return 1+int(rand(6));
242             }
243              
244             sub roll2d6 {
245 812     812 0 1477 return roll1d6() + roll1d6();
246             }
247              
248             sub xy {
249 77     77 0 119 my $self = shift;
250 77         128 my $i = shift;
251 77         167 my $y = int($i / $self->cols);
252 77         418 my $x = $i % $self->cols;
253 77         491 $log->debug("$i ($x, $y)");
254 77         605 return $x + 1, $y + 1;
255             }
256              
257             sub label {
258 90     90 0 165 my ($self, $from, $to, $d, $label) = @_;
259 90         364 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 7 my $self = shift;
266 2         10 my %systems = %{shift()};
  2         43  
267 2         22 my @coordinates = map { [ $self->xy($_), $systems{$_} ] } keys(%systems);
  77         163  
268 2         19 my @comms;
269             my @trade;
270 2         0 my @rich_trade;
271 2         12 while (@coordinates) {
272 77         179 my $from = shift(@coordinates);
273 77         210 my ($x1, $y1, $system1) = @$from;
274 77 100   1061   366 next if any { /^starport-X$/ } @$system1; # skip systems without starports
  1061         1772  
275 74         246 for my $to (@coordinates) {
276 1368         2995 my ($x2, $y2, $system2) = @$to;
277 1368 100   18979   4609 next if any { /^starport-X$/ } @$system2; # skip systems without starports
  18979         28955  
278 1348         4726 my $d = $self->distance($x1, $y1, $x2, $y2);
279 1348 100 100     3727 if ($d <= 2 and match(qr/^(starport-[AB]|naval)$/, qr/^(starport-[AB]|naval)$/, $system1, $system2)) {
280 43         204 push(@comms, [$from, $to, $d]);
281             }
282 1348 100 66     3562 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 88         340 push(@trade, [$from, $to, $d]);
325             }
326 1348 100 100     6106 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 9         61 push(@rich_trade, [$from, $to, $d]);
332             }
333             }
334             }
335 2         8 @comms = sort map { $self->label(@$_, "communication") } @{$self->minimal_spanning_tree(@comms)};
  25         51  
  2         17  
336 2         15 @trade = sort map { $self->label(@$_, "trade") } @{$self->minimal_spanning_tree(@trade)};
  56         111  
  2         14  
337 2         29 @rich_trade = sort map { $self->label(@$_, "rich") } @{$self->minimal_spanning_tree(@rich_trade)};
  9         22  
  2         10  
338 2         53 return [@rich_trade, @comms, @trade];
339             }
340              
341             sub match {
342 3402     3402 0 8254 my ($re1, $re2, $sys1, $sys2) = @_;
343 3402 100 100 21227   10138 return 1 if any { /$re1/ } @$sys1 and any { /$re2/ } @$sys2;
  43732         119233  
  6067         15678  
344 3299 100 100 36403   12297 return 1 if any { /$re2/ } @$sys1 and any { /$re1/ } @$sys2;
  36403         95981  
  15577         36335  
345 3262         23046 return 0;
346             }
347              
348             sub minimal_spanning_tree {
349             # http://en.wikipedia.org/wiki/Kruskal%27s_algorithm
350 6     6 0 12 my $self = shift;
351             # Initialize a priority queue Q to contain all edges in G, using the
352             # weights as keys.
353 6         33 my @Q = sort { @{$a}[2] <=> @{$b}[2] } @_;
  463         736  
  463         764  
  463         843  
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         14 foreach my $edge (@Q) {
360             # edge u,v is the minimum weighted route from u to v
361 140         190 my ($u, $v) = @{$edge};
  140         274  
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 140 100 100     719 if (not $C{$u} or not $C{$v} or $C{$u} != $C{$v}) {
      100        
365             # Add edge (v,u) to T.
366 90         154 push(@T, $edge);
367             # Merge C(v) and C(u) into one cluster, that is, union C(v) and C(u).
368 90 100 100     554 if ($C{$u} and $C{$v}) {
    100 66        
    100 66        
    50 33        
369 17         31 my @group;
370 17         83 foreach (keys %C) {
371 375 100       929 push(@group, $_) if $C{$_} == $C{$v};
372             }
373 17         123 $C{$_} = $C{$u} foreach @group;
374             } elsif ($C{$v} and not $C{$u}) {
375 13         43 $C{$u} = $C{$v};
376             } elsif ($C{$u} and not $C{$v}) {
377 35         131 $C{$v} = $C{$u};
378             } elsif (not $C{$u} and not $C{$v}) {
379 25         103 $C{$v} = $C{$u} = ++$id;
380             }
381             }
382             }
383 6         48 return \@T;
384             }
385              
386             sub to_text {
387 2     2 0 7 my $self = shift;
388 2         5 my $tiles = shift;
389 2         4 my $comms = shift;
390 2         5 my $text = "";
391 2         17 for my $x (0 .. $self->cols - 1) {
392 16         56 for my $y (0 .. $self->rows - 1) {
393 160         315 my $tile = $tiles->[$x + $y * $self->cols];
394 160 50       575 if ($tile) {
395 160         606 $text .= sprintf("%02d%02d @$tile\n", $x + 1, $y + 1);
396             }
397             }
398             }
399 2         21 $text .= join("\n", @$comms, "\ninclude traveller.txt\n");
400 2         12 $text .= $self->legend();
401 2         416 return $text;
402             }
403              
404             sub legend {
405 2     2 0 5 my $self = shift;
406 2         6 my $template = qq{# frame and legend};
407 2         8 my $x = int(($self->cols + 1) * 1.5 * $dx);
408 2         17 my $y = int(($self->rows + 1) * $dy + 5);
409 2         22 $template .= qq{
410             other },
411             $x = int(($self->cols + 1) * 0.75 * $dx);
412 2         12 $y = int(($self->rows + 1) * $dy - 60);
413 2         16 $template .= qq{
414             other coreward
415             other rimward};
416 2         6 $x = int($self->rows * $dy / 2);
417 2         13 $template .= qq{
418             other spinward
419             };
420 2         7 $y = int(($self->cols + 1) * 1.5 * $dx);
421 2         13 $template .= qq{
422             other trailing
423             };
424 2         8 $x = int(($self->rows + 0.5) * $dy);
425 2 50       13 $template .= qq{
426             other ◉ gas giant – ▲ scout base – ★ navy base – π research base – ☠ pirate base
427             } if $self->rows > 8;
428 2 50       18 $template .= qq{
429             other ■ imperial consulate – ☼ TAS – communication – trade long distance trade
430             } if $self->rows > 8;
431 2         30 return $template;
432             }
433              
434             1;