File Coverage

blib/lib/Game/TextMapper/Traveller.pm
Criterion Covered Total %
statement 239 239 100.0
branch 135 144 93.7
condition 111 120 92.5
subroutine 26 26 100.0
pod 1 15 6.6
total 512 544 94.1


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   71 use Game::TextMapper::Log;
  11         23  
  11         504  
35 11     11   48 use Modern::Perl '2018';
  11         20  
  11         122  
36 11     11   3312 use List::Util qw(shuffle max any);
  11         18  
  11         943  
37 11     11   63 use Mojo::Base -base;
  11         18  
  11         75  
38 11     11   1962 use Role::Tiny::With;
  11         23  
  11         641  
39 11     11   61 use Game::TextMapper::Constants qw($dx $dy);
  11         17  
  11         51067  
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 23 my $self = shift;
56 2         10 $self->digraphs($self->compute_digraphs);
57             # coordinates are an index into the system array
58 2         24 my @coordinates = (0 .. $self->rows * $self->cols - 1);
59 2         44 my @randomized = shuffle(@coordinates);
60             # %systems maps coordinates to arrays of tiles
61 2         6 my %systems = map { $_ => $self->system() } grep { roll1d6() > 3 } @randomized; # density
  84         119  
  160         183  
62 2         25 my $comms = $self->comms(\%systems);
63 2 100       9 my $tiles = [map { $systems{$_} || ["empty"] } (@coordinates)];
  160         398  
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 84     84 0 87 my $self = shift;
70 84         102 my $size = roll2d6() - 2;
71 84         124 my $atmosphere = max(0, roll2d6() - 7 + $size);
72 84 100       147 $atmosphere = 0 if $size == 0;
73 84         92 my $hydro = roll2d6() - 7 + $atmosphere;
74 84 100 100     184 $hydro -= 4 if $atmosphere < 2 or $atmosphere >= 10;
75 84 100 100     186 $hydro = 0 if $hydro < 0 or $size < 2;
76 84 100       125 $hydro = 10 if $hydro > 10;
77 84         101 my $population = roll2d6() - 2;
78 84         114 my $government = max(0, roll2d6() - 7 + $population);
79 84         109 my $law = max(0, roll2d6() - 7 + $government);
80 84         103 my $starport = roll2d6();
81 84         149 my $naval_base = 0;
82 84         82 my $scout_base = 0;
83 84         87 my $research_base = 0;
84 84         83 my $pirate_base = 0;
85 84         92 my $tech = roll1d6();
86 84 100       176 if ($starport <= 4) {
    100          
    100          
    100          
    100          
87 16         20 $starport = "A";
88 16         17 $tech += 6;
89 16 100       19 $scout_base = 1 if roll2d6() >= 10;
90 16 100       36 $naval_base = 1 if roll2d6() >= 8;
91 16 100       23 $research_base = 1 if roll2d6() >= 8;
92             } elsif ($starport <= 6) {
93 23         38 $starport = "B";
94 23         24 $tech += 4;
95 23 100       30 $scout_base = 1 if roll2d6() >= 9;
96 23 100       30 $naval_base = 1 if roll2d6() >= 8;
97 23 100       32 $research_base = 1 if roll2d6() >= 10;
98             } elsif ($starport <= 8) {
99 26         28 $starport = "C";
100 26         29 $tech += 2;
101 26 100       33 $scout_base = 1 if roll2d6() >= 8;
102 26 100       32 $research_base = 1 if roll2d6() >= 10;
103 26 50       34 $pirate_base = 1 if roll2d6() >= 12;
104             } elsif ($starport <= 9) {
105 14         17 $starport = "D";
106 14 100       18 $scout_base = 1 if roll2d6() >= 7;
107 14 100       24 $pirate_base = 1 if roll2d6() >= 10;
108             } elsif ($starport <= 11) {
109 3         5 $starport = "E";
110 3 50       6 $pirate_base = 1 if roll2d6() >= 10;
111             } else {
112 2         7 $starport = "X";
113 2         4 $tech -= 4;
114             }
115 84 100       125 $tech += 1 if $size <= 4;
116 84 100       117 $tech += 1 if $size <= 1; # +2 total
117 84 100 100     172 $tech += 1 if $atmosphere <= 3 or $atmosphere >= 10;
118 84 100       113 $tech += 1 if $hydro >= 9;
119 84 100       126 $tech += 1 if $hydro >= 10; # +2 total
120 84 100 100     176 $tech += 1 if $population >= 1 and $population <= 5;
121 84 100       107 $tech += 2 if $population >= 9;
122 84 100       115 $tech += 2 if $population >= 10; # +4 total
123 84 100 100     167 $tech += 1 if $government == 0 or $government == 5;
124 84 100       110 $tech -= 2 if $government == 13; # D
125 84 100       103 $tech = 0 if $tech < 0;
126 84         96 my $gas_giant = roll2d6() <= 9;
127 84         121 my $name = $self->compute_name();
128 84 100       131 $name = uc($name) if $population >= 9;
129 84         106 my $uwp = join("", $starport, map { code($_) } $size, $atmosphere, $hydro, $population, $government, $law) . "-" . code($tech);
  504         550  
130             # these things determine the order in which text is generated by Hex Describe
131 84         128 my @tiles;
132 84 100       136 push(@tiles, "gas") if $gas_giant;
133 84         103 push(@tiles, "size-" . code($size));
134 84 100       126 push(@tiles, "asteroid")
135             if $size == 0;
136 84         99 push(@tiles, "atmosphere-" . code($atmosphere));
137 84 100       121 push(@tiles, "vacuum")
138             if $atmosphere == 0;
139 84         96 push(@tiles, "hydrosphere-" . code($hydro));
140 84 50       110 push(@tiles, "water")
141             if $hydro eq "A";
142 84 100 100     168 push(@tiles, "desert")
143             if $atmosphere >= 2
144             and $hydro == 0;
145 84 50 66     160 push(@tiles, "ice")
146             if $hydro >= 1
147             and $atmosphere <= 1;
148 84 100 100     170 push(@tiles, "fluid")
149             if $hydro >= 1
150             and $atmosphere >= 10;
151 84         101 push(@tiles, "population-" . code($population));
152 84 50 66     185 push(@tiles, "barren")
      66        
153             if $population eq 0
154             and $law eq 0
155             and $government eq 0;
156 84 100 100     179 push(@tiles, "low")
157             if $population >= 1 and $population <= 3;
158 84 100       128 push(@tiles, "high")
159             if $population >= 9;
160 84 100 100     282 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 84 100 100     175 push(@tiles, "non-agriculture")
      100        
165             if $atmosphere <= 3
166             and $hydro <= 3
167             and $population >= 6;
168             push(@tiles, "industrial")
169 84 100 100 379   250 if any { $atmosphere == $_ } 0, 1, 2, 4, 7, 9
  379         442  
170             and $population >= 9;
171 84 100       222 push(@tiles, "non-industrial")
172             if $population <= 6;
173 84 100 100     282 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 84 100 100     198 push(@tiles, "poor")
      100        
178             if $atmosphere >= 2 and $atmosphere <= 5
179             and $hydro <= 3;
180 84         99 push(@tiles, "tech-" . code($tech));
181 84         116 push(@tiles, "government-" . code($government));
182 84         125 push(@tiles, "starport-$starport");
183 84         102 push(@tiles, "law-" . code($law));
184 84 100       124 push(@tiles, "naval") if $naval_base;
185 84 100       112 push(@tiles, "scout") if $scout_base;
186 84 100       105 push(@tiles, "research") if $research_base;
187 84 100       111 push(@tiles, "pirate", "red") if $pirate_base;
188 84 100 100     468 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 84         161 push(@tiles, qq{name="$name"}, qq{uwp="$uwp"});
198 84         215 return \@tiles;
199             }
200              
201             sub code {
202 1176     1176 0 1114 my $code = shift;
203 1176 100       1940 return $code if $code <= 9;
204 162         293 return chr(55+$code); # 10 is A
205             }
206              
207             sub compute_digraphs {
208 2     2 0 43 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         9 my @second = qw(a e i o u a e i o u a e i o u .);
214 2         3 my @d;
215 2         11 for (1 .. 10+rand(20)) {
216 43         96 push(@d, one(@first));
217 43         53 push(@d, one(@second));
218             }
219 2         17 return \@d;
220             }
221              
222             sub compute_name {
223 84     84 0 87 my $self = shift;
224 84         82 my $max = scalar @{$self->digraphs};
  84         133  
225 84         270 my $length = 3 + rand(3); # length of name before adding one more
226 84         88 my $name = '';
227 84         140 while (length($name) < $length) {
228 210         441 my $i = 2*int(rand($max/2));
229 210         271 $name .= $self->digraphs->[$i];
230 210         558 $name .= $self->digraphs->[$i+1];
231             }
232 84         279 $name =~ s/\.//g;
233 84         164 return ucfirst($name);
234             }
235              
236             sub one {
237 86     86 0 126 return $_[int(rand(scalar @_))];
238             }
239              
240             sub roll1d6 {
241 2040     2040 0 2666 return 1+int(rand(6));
242             }
243              
244             sub roll2d6 {
245 898     898 0 951 return roll1d6() + roll1d6();
246             }
247              
248             sub xy {
249 84     84 0 79 my $self = shift;
250 84         78 my $i = shift;
251 84         104 my $y = int($i / $self->cols);
252 84         257 my $x = $i % $self->cols;
253 84         285 $log->debug("$i ($x, $y)");
254 84         351 return $x + 1, $y + 1;
255             }
256              
257             sub label {
258 133     133 0 207 my ($self, $from, $to, $d, $label) = @_;
259 133         474 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 3 my $self = shift;
266 2         4 my %systems = %{shift()};
  2         26  
267 2         14 my @coordinates = map { [ $self->xy($_), $systems{$_} ] } keys(%systems);
  84         102  
268 2         20 my @comms;
269             my @trade;
270 2         0 my @rich_trade;
271 2         8 while (@coordinates) {
272 84         114 my $from = shift(@coordinates);
273 84         129 my ($x1, $y1, $system1) = @$from;
274 84 100   1180   212 next if any { /^starport-X$/ } @$system1; # skip systems without starports
  1180         1361  
275 82         187 for my $to (@coordinates) {
276 1667         2308 my ($x2, $y2, $system2) = @$to;
277 1667 100   23465   3652 next if any { /^starport-X$/ } @$system2; # skip systems without starports
  23465         24560  
278 1656         3632 my $d = $self->distance($x1, $y1, $x2, $y2);
279 1656 100 100     3000 if ($d <= 2 and match(qr/^(starport-[AB]|naval)$/, qr/^(starport-[AB]|naval)$/, $system1, $system2)) {
280 64         179 push(@comms, [$from, $to, $d]);
281             }
282 1656 100 66     2831 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 125         264 push(@trade, [$from, $to, $d]);
325             }
326 1656 100 100     4573 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 33         141 push(@rich_trade, [$from, $to, $d]);
332             }
333             }
334             }
335 2         8 @comms = sort map { $self->label(@$_, "communication") } @{$self->minimal_spanning_tree(@comms)};
  35         62  
  2         30  
336 2         17 @trade = sort map { $self->label(@$_, "trade") } @{$self->minimal_spanning_tree(@trade)};
  69         126  
  2         14  
337 2         31 @rich_trade = sort map { $self->label(@$_, "rich") } @{$self->minimal_spanning_tree(@rich_trade)};
  29         48  
  2         15  
338 2         63 return [@rich_trade, @comms, @trade];
339             }
340              
341             sub match {
342 3466     3466 0 5028 my ($re1, $re2, $sys1, $sys2) = @_;
343 3466 100 100 45155   6957 return 1 if any { /$re1/ } @$sys1 and any { /$re2/ } @$sys2;
  45155         74232  
  6896         12735  
344 3321 100 100 36664   8491 return 1 if any { /$re2/ } @$sys1 and any { /$re1/ } @$sys2;
  36664         67623  
  16251         26446  
345 3244         13084 return 0;
346             }
347              
348             sub minimal_spanning_tree {
349             # http://en.wikipedia.org/wiki/Kruskal%27s_algorithm
350 6     6 0 15 my $self = shift;
351             # Initialize a priority queue Q to contain all edges in G, using the
352             # weights as keys.
353 6         32 my @Q = sort { @{$a}[2] <=> @{$b}[2] } @_;
  780         878  
  780         921  
  780         1070  
354             # Define a forest T ← Ø; T will ultimately contain the edges of the MST
355 6         22 my @T;
356             # Define an elementary cluster C(v) ← {v}.
357             my %C;
358 6         0 my $id;
359 6         15 foreach my $edge (@Q) {
360             # edge u,v is the minimum weighted route from u to v
361 222         268 my ($u, $v) = @{$edge};
  222         329  
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 222 100 100     745 if (not $C{$u} or not $C{$v} or $C{$u} != $C{$v}) {
      100        
365             # Add edge (v,u) to T.
366 133         210 push(@T, $edge);
367             # Merge C(v) and C(u) into one cluster, that is, union C(v) and C(u).
368 133 100 100     588 if ($C{$u} and $C{$v}) {
    100 66        
    100 66        
    50 33        
369 24         28 my @group;
370 24         96 foreach (keys %C) {
371 504 100       984 push(@group, $_) if $C{$_} == $C{$v};
372             }
373 24         147 $C{$_} = $C{$u} foreach @group;
374             } elsif ($C{$v} and not $C{$u}) {
375 29         88 $C{$u} = $C{$v};
376             } elsif ($C{$u} and not $C{$v}) {
377 47         138 $C{$v} = $C{$u};
378             } elsif (not $C{$u} and not $C{$v}) {
379 33         146 $C{$v} = $C{$u} = ++$id;
380             }
381             }
382             }
383 6         61 return \@T;
384             }
385              
386             sub to_text {
387 2     2 0 6 my $self = shift;
388 2         4 my $tiles = shift;
389 2         4 my $comms = shift;
390 2         5 my $text = "";
391 2         19 for my $x (0 .. $self->cols - 1) {
392 16         55 for my $y (0 .. $self->rows - 1) {
393 160         297 my $tile = $tiles->[$x + $y * $self->cols];
394 160 50       526 if ($tile) {
395 160         557 $text .= sprintf("%02d%02d @$tile\n", $x + 1, $y + 1);
396             }
397             }
398             }
399 2         26 $text .= join("\n", @$comms, "\ninclude traveller.txt\n");
400 2         12 $text .= $self->legend();
401 2         473 return $text;
402             }
403              
404             sub legend {
405 2     2 0 6 my $self = shift;
406 2         4 my $template = qq{# frame and legend};
407 2         11 my $x = int(($self->cols + 1) * 1.5 * $dx);
408 2         20 my $y = int(($self->rows + 1) * $dy + 5);
409 2         21 $template .= qq{
410             other },
411             $x = int(($self->cols + 1) * 0.75 * $dx);
412 2         14 $y = int(($self->rows + 1) * $dy - 60);
413 2         16 $template .= qq{
414             other coreward
415             other rimward};
416 2         7 $x = int($self->rows * $dy / 2);
417 2         15 $template .= qq{
418             other spinward
419             };
420 2         8 $y = int(($self->cols + 1) * 1.5 * $dx);
421 2         17 $template .= qq{
422             other trailing
423             };
424 2         7 $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       19 $template .= qq{
429             other ■ imperial consulate – ☼ TAS – communication – trade long distance trade
430             } if $self->rows > 8;
431 2         43 return $template;
432             }
433              
434             1;